From bca401e2fd9634dc0f24ddef98bcab00e15684f1 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 16 Jul 2025 19:37:33 +0100
Subject: [PATCH 01/38] chore(nodejs): extract transport from sender, add back
support for core http
---
.gitignore | 1 -
src/logging.ts | 11 +-
src/options.ts | 91 +-
src/sender.ts | 873 ++-------
src/transport/http/base.ts | 114 ++
src/transport/http/legacy.ts | 165 ++
src/transport/http/undici.ts | 143 ++
src/transport/index.ts | 36 +
src/transport/tcp.ts | 302 +++
src/utils.ts | 37 +
test/logging.test.ts | 20 +-
test/options.test.ts | 10 +-
test/sender.buffer.test.ts | 803 ++++++++
test/sender.config.test.ts | 402 ++++
test/sender.integration.test.ts | 392 ++++
test/sender.test.ts | 2176 ----------------------
test/sender.transport.test.ts | 568 ++++++
test/testapp.ts | 9 +-
test/{_utils_ => util}/mockhttp.ts | 36 +-
test/{_utils_ => util}/mockproxy.ts | 19 +-
test/{_utils_ => util}/proxy.ts | 12 +-
test/{_utils_ => util}/proxyfunctions.ts | 24 +-
22 files changed, 3271 insertions(+), 2973 deletions(-)
create mode 100644 src/transport/http/base.ts
create mode 100644 src/transport/http/legacy.ts
create mode 100644 src/transport/http/undici.ts
create mode 100644 src/transport/index.ts
create mode 100644 src/transport/tcp.ts
create mode 100644 src/utils.ts
create mode 100644 test/sender.buffer.test.ts
create mode 100644 test/sender.config.test.ts
create mode 100644 test/sender.integration.test.ts
delete mode 100644 test/sender.test.ts
create mode 100644 test/sender.transport.test.ts
rename test/{_utils_ => util}/mockhttp.ts (74%)
rename test/{_utils_ => util}/mockproxy.ts (78%)
rename test/{_utils_ => util}/proxy.ts (78%)
rename test/{_utils_ => util}/proxyfunctions.ts (66%)
diff --git a/.gitignore b/.gitignore
index 24018fa..b1870ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,4 @@ coverage
.idea
*.iml
.DS_Store
-certs
dist
\ No newline at end of file
diff --git a/src/logging.ts b/src/logging.ts
index 58b4ddf..48098b2 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -7,15 +7,20 @@ const LOG_LEVELS = {
const DEFAULT_CRITICALITY = LOG_LEVELS.info.criticality;
+type Logger = (
+ level: "error" | "warn" | "info" | "debug",
+ message: string | Error,
+) => void;
+
/**
* Simple logger to write log messages to the console.
* Supported logging levels are `error`, `warn`, `info` and `debug`.
* Throws an error if logging level is invalid.
*
* @param {'error'|'warn'|'info'|'debug'} level - The log level of the message.
- * @param {string} message - The log message.
+ * @param {string | Error} message - The log message.
*/
-function log(level: "error" | "warn" | "info" | "debug", message: string) {
+function log(level: "error" | "warn" | "info" | "debug", message: string | Error) {
const logLevel = LOG_LEVELS[level];
if (!logLevel) {
throw new Error(`Invalid log level: '${level}'`);
@@ -25,4 +30,4 @@ function log(level: "error" | "warn" | "info" | "debug", message: string) {
}
}
-export { log };
+export { log, Logger };
diff --git a/src/options.ts b/src/options.ts
index 9b32645..ee39178 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -1,5 +1,9 @@
import { PathOrFileDescriptor } from "fs";
import { Agent } from "undici";
+import http from "http";
+import https from "https";
+
+import { Logger } from "./logging";
const HTTP_PORT = 9000;
const TCP_PORT = 9009;
@@ -13,6 +17,20 @@ const ON = "on";
const OFF = "off";
const UNSAFE_OFF = "unsafe_off";
+type ExtraOptions = {
+ log?: Logger;
+ agent?: Agent | http.Agent | https.Agent;
+}
+
+type DeprecatedOptions = {
+ /** @deprecated */
+ copy_buffer?: boolean;
+ /** @deprecated */
+ copyBuffer?: boolean;
+ /** @deprecated */
+ bufferSize?: number;
+};
+
/** @classdesc
* Sender configuration options.
*
@@ -139,10 +157,10 @@ class SenderOptions {
max_name_len?: number;
- log?:
- | ((level: "error" | "warn" | "info" | "debug", message: string) => void)
- | null;
- agent?: Agent;
+ log?: Logger;
+ agent?: Agent | http.Agent | https.Agent;
+
+ legacy_http?: boolean;
auth?: {
username?: string;
@@ -162,7 +180,7 @@ class SenderOptions {
* - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
* A http.Agent or https.Agent object is expected.
*/
- constructor(configurationString: string, extraOptions = undefined) {
+ constructor(configurationString: string, extraOptions: ExtraOptions = undefined) {
parseConfigurationString(this, configurationString);
if (extraOptions) {
@@ -171,13 +189,36 @@ class SenderOptions {
}
this.log = extraOptions.log;
- if (extraOptions.agent && !(extraOptions.agent instanceof Agent)) {
- throw new Error("Invalid http/https agent");
+ if (
+ extraOptions.agent
+ && !(extraOptions.agent instanceof Agent)
+ && !(extraOptions.agent instanceof http.Agent)
+ // @ts-ignore
+ && !(extraOptions.agent instanceof https.Agent)
+ ) {
+ throw new Error("Invalid HTTP agent");
}
this.agent = extraOptions.agent;
}
}
+ static resolveDeprecated(options: SenderOptions & DeprecatedOptions, log: Logger) {
+ // deal with deprecated options
+ if (options.copy_buffer !== undefined) {
+ log("warn", `Option 'copy_buffer' is not supported anymore, please, remove it`);
+ options.copy_buffer = undefined;
+ }
+ if (options.copyBuffer !== undefined) {
+ log("warn", `Option 'copyBuffer' is not supported anymore, please, remove it`);
+ options.copyBuffer = undefined;
+ }
+ if (options.bufferSize !== undefined) {
+ log("warn", `Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'`);
+ options.init_buf_size = options.bufferSize;
+ options.bufferSize = undefined;
+ }
+ }
+
/**
* Creates a Sender options object by parsing the provided configuration string.
*
@@ -190,16 +231,7 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
*/
- static fromConfig(
- configurationString: string,
- extraOptions: {
- log?: (
- level: "error" | "warn" | "info" | "debug",
- message: string,
- ) => void;
- agent?: Agent;
- } = undefined,
- ) {
+ static fromConfig(configurationString: string, extraOptions: ExtraOptions = undefined): SenderOptions {
return new SenderOptions(configurationString, extraOptions);
}
@@ -214,15 +246,12 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.
*/
- static fromEnv(extraOptions = undefined) {
+ static fromEnv(extraOptions: ExtraOptions = undefined): SenderOptions {
return SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions);
}
}
-function parseConfigurationString(
- options: SenderOptions,
- configString: string,
-) {
+function parseConfigurationString(options: SenderOptions, configString: string) {
if (!configString) {
throw new Error("Configuration string is missing or empty");
}
@@ -235,6 +264,7 @@ function parseConfigurationString(
parseTlsOptions(options);
parseRequestTimeoutOptions(options);
parseMaxNameLength(options);
+ parseLegacyTransport(options);
}
function parseSettings(
@@ -244,10 +274,7 @@ function parseSettings(
) {
let index = configString.indexOf(";", position);
while (index > -1) {
- if (
- index + 1 < configString.length &&
- configString.charAt(index + 1) === ";"
- ) {
+ if (index + 1 < configString.length && configString.charAt(index + 1) === ";") {
index = configString.indexOf(";", index + 2);
continue;
}
@@ -297,6 +324,7 @@ const ValidConfigKeys = [
"max_buf_size",
"max_name_len",
"tls_verify",
+ "legacy_http",
"tls_ca",
"tls_roots",
"tls_roots_password",
@@ -322,10 +350,7 @@ function validateConfigValue(key: string, value: string) {
}
}
-function parseProtocol(
- options: SenderOptions,
- configString: string | string[],
-) {
+function parseProtocol(options: SenderOptions, configString: string) {
const index = configString.indexOf("::");
if (index < 0) {
throw new Error(
@@ -422,6 +447,10 @@ function parseMaxNameLength(options: SenderOptions) {
parseInteger(options, "max_name_len", "max name length", 1);
}
+function parseLegacyTransport(options: SenderOptions) {
+ parseBoolean(options, "legacy_http", "legacy http");
+}
+
function parseBoolean(
options: SenderOptions,
property: string,
@@ -466,4 +495,4 @@ function parseInteger(
}
}
-export { SenderOptions, HTTP, HTTPS, TCP, TCPS };
+export { SenderOptions, ExtraOptions, HTTP, HTTPS, TCP, TCPS };
diff --git a/src/sender.ts b/src/sender.ts
index 4cc6cb9..9ad0786 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -1,61 +1,19 @@
// @ts-check
-import { readFileSync } from "node:fs";
import { Buffer } from "node:buffer";
-import net from "node:net";
-import tls from "node:tls";
-import crypto from "node:crypto";
-import { Agent, RetryAgent } from "undici";
-import { log } from "./logging";
+import { log, Logger } from "./logging";
import { validateColumnName, validateTableName } from "./validation";
-import { SenderOptions, HTTP, HTTPS, TCP, TCPS } from "./options";
+import { SenderOptions, ExtraOptions } from "./options";
+import { SenderTransport, createTransport } from "./transport";
+import { isBoolean, isInteger, timestampToMicros, timestampToNanos } from "./utils";
-const HTTP_NO_CONTENT = 204; // success
-
-const DEFAULT_HTTP_AUTO_FLUSH_ROWS = 75000;
-const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600;
const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
const DEFAULT_MAX_NAME_LENGTH = 127;
-const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400; // 100 KB/sec
-const DEFAULT_REQUEST_TIMEOUT = 10000; // 10 sec
-const DEFAULT_RETRY_TIMEOUT = 10000; // 10 sec
-
const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
-/** @type {Agent.Options} */
-const DEFAULT_HTTP_OPTIONS: Agent.Options = {
- connect: {
- keepAlive: true,
- },
- pipelining: 1,
- keepAliveTimeout: 60000, // 1 minute
-};
-// an arbitrary public key, not used in authentication
-// only used to construct a valid JWK token which is accepted by the crypto API
-const PUBLIC_KEY = {
- x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc",
- y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg",
-};
-
-/*
-We are retrying on the following response codes (copied from the Rust client):
-500: Internal Server Error
-503: Service Unavailable
-504: Gateway Timeout
-
-// Unofficial extensions
-507: Insufficient Storage
-509: Bandwidth Limit Exceeded
-523: Origin is Unreachable
-524: A Timeout Occurred
-529: Site is overloaded
-599: Network Connect Timeout Error
-*/
-const RETRIABLE_STATUS_CODES = [500, 503, 504, 507, 509, 523, 524, 529, 599];
-
/** @classdesc
* The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
* The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
@@ -101,48 +59,27 @@ const RETRIABLE_STATUS_CODES = [500, 503, 504, 507, 509, 523, 524, 529, 599];
*
*/
class Sender {
- /** @private */ static DEFAULT_HTTP_AGENT;
- /** @private */ static DEFAULT_HTTPS_AGENT;
-
- /** @private */ http; // true if the protocol is HTTP/HTTPS, false if it is TCP/TCPS
- /** @private */ secure; // true if the protocol is HTTPS or TCPS, false otherwise
- /** @private */ host;
- /** @private */ port;
-
- /** @private */ socket;
-
- /** @private */ username;
- /** @private */ password;
- /** @private */ token;
-
- /** @private */ tlsVerify;
- /** @private */ tlsCA;
+ private readonly transport: SenderTransport;
- /** @private */ bufferSize;
- /** @private */ maxBufferSize;
- /** @private */ buffer;
- /** @private */ position;
- /** @private */ endOfLastRow;
+ private bufferSize: number;
+ private readonly maxBufferSize: number;
+ private buffer: Buffer;
+ private position: number;
+ private endOfLastRow: number;
- /** @private */ autoFlush;
- /** @private */ autoFlushRows;
- /** @private */ autoFlushInterval;
- /** @private */ lastFlushTime;
- /** @private */ pendingRowCount;
+ private readonly autoFlush: boolean;
+ private readonly autoFlushRows: number;
+ private readonly autoFlushInterval: number;
+ private lastFlushTime: number;
+ private pendingRowCount: number;
- /** @private */ requestMinThroughput;
- /** @private */ requestTimeout;
- /** @private */ retryTimeout;
+ private hasTable: boolean;
+ private hasSymbols: boolean;
+ private hasColumns: boolean;
- /** @private */ hasTable;
- /** @private */ hasSymbols;
- /** @private */ hasColumns;
+ private readonly maxNameLength: number;
- /** @private */ maxNameLength;
-
- /** @private */ log;
- /** @private */ agent;
- /** @private */ jwk;
+ private readonly log: Logger;
/**
* Creates an instance of Sender.
@@ -151,80 +88,15 @@ class Sender {
* See SenderOptions documentation for detailed description of configuration options.
*/
constructor(options: SenderOptions) {
- if (!options || !options.protocol) {
- throw new Error("The 'protocol' option is mandatory");
- }
- this.log = typeof options.log === "function" ? options.log : log;
- replaceDeprecatedOptions(options, this.log);
-
- switch (options.protocol) {
- case HTTP:
- this.http = true;
- this.secure = false;
- this.agent =
- options.agent instanceof Agent
- ? options.agent
- : Sender.getDefaultHttpAgent();
- break;
- case HTTPS:
- this.http = true;
- this.secure = true;
- if (options.agent instanceof Agent) {
- this.agent = options.agent;
- } else {
- // Create a new agent with instance-specific TLS options
- this.agent = new Agent({
- ...DEFAULT_HTTP_OPTIONS,
- connect: {
- ...DEFAULT_HTTP_OPTIONS.connect,
- requestCert: isBoolean(options.tls_verify) ? options.tls_verify : true,
- rejectUnauthorized: isBoolean(options.tls_verify) ? options.tls_verify : true,
- ca: options.tls_ca ? readFileSync(options.tls_ca) : undefined,
- },
- });
- }
- break;
- case TCP:
- this.http = false;
- this.secure = false;
- break;
- case TCPS:
- this.http = false;
- this.secure = true;
- break;
- default:
- throw new Error(`Invalid protocol: '${options.protocol}'`);
- }
+ this.transport = createTransport(options);
- if (this.http) {
- this.username = options.username;
- this.password = options.password;
- this.token = options.token;
- if (!options.port) {
- options.port = 9000;
- }
- } else {
- if (!options.auth && !options.jwk) {
- constructAuth(options);
- }
- this.jwk = constructJwk(options);
- if (!options.port) {
- options.port = 9009;
- }
- }
-
- this.host = options.host;
- this.port = options.port;
-
- this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
- this.tlsCA = options.tls_ca ? readFileSync(options.tls_ca) : undefined;
+ this.log = typeof options.log === "function" ? options.log : log;
+ SenderOptions.resolveDeprecated(options, this.log);
this.autoFlush = isBoolean(options.auto_flush) ? options.auto_flush : true;
this.autoFlushRows = isInteger(options.auto_flush_rows, 0)
? options.auto_flush_rows
- : this.http
- ? DEFAULT_HTTP_AUTO_FLUSH_ROWS
- : DEFAULT_TCP_AUTO_FLUSH_ROWS;
+ : this.transport.getDefaultAutoFlushRows();
this.autoFlushInterval = isInteger(options.auto_flush_interval, 0)
? options.auto_flush_interval
: DEFAULT_AUTO_FLUSH_INTERVAL;
@@ -233,16 +105,6 @@ class Sender {
? options.max_name_len
: DEFAULT_MAX_NAME_LENGTH;
- this.requestMinThroughput = isInteger(options.request_min_throughput, 0)
- ? options.request_min_throughput
- : DEFAULT_REQUEST_MIN_THROUGHPUT;
- this.requestTimeout = isInteger(options.request_timeout, 1)
- ? options.request_timeout
- : DEFAULT_REQUEST_TIMEOUT;
- this.retryTimeout = isInteger(options.retry_timeout, 0)
- ? options.retry_timeout
- : DEFAULT_RETRY_TIMEOUT;
-
this.maxBufferSize = isInteger(options.max_buf_size, 1)
? options.max_buf_size
: DEFAULT_MAX_BUFFER_SIZE;
@@ -266,10 +128,7 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the provided configuration string.
*/
- static fromConfig(
- configurationString: string,
- extraOptions: object = undefined,
- ): Sender {
+ static fromConfig(configurationString: string, extraOptions: ExtraOptions = undefined): Sender {
return new Sender(
SenderOptions.fromConfig(configurationString, extraOptions),
);
@@ -286,7 +145,7 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the QDB_CLIENT_CONF environment variable.
*/
- static fromEnv(extraOptions: object = undefined): Sender {
+ static fromEnv(extraOptions: ExtraOptions = undefined): Sender {
return new Sender(
SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions),
);
@@ -299,11 +158,9 @@ class Sender {
*
* @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
*/
- resize(bufferSize: number) {
+ private resize(bufferSize: number) {
if (bufferSize > this.maxBufferSize) {
- throw new Error(
- `Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`,
- );
+ throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
}
this.bufferSize = bufferSize;
// Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
@@ -327,109 +184,17 @@ class Sender {
this.position = 0;
this.lastFlushTime = Date.now();
this.pendingRowCount = 0;
- startNewRow(this);
+ this.startNewRow();
return this;
}
/**
* Creates a TCP connection to the database.
*
- * @param {net.NetConnectOpts | tls.ConnectionOptions} connectOptions - Connection options, host and port are required.
- *
* @return {Promise} Resolves to true if the client is connected.
*/
- connect(
- connectOptions: net.NetConnectOpts | tls.ConnectionOptions = undefined,
- ): Promise {
- if (this.http) {
- throw new Error(
- "'connect()' should be called only if the sender connects via TCP",
- );
- }
-
- if (!connectOptions) {
- connectOptions = {
- host: this.host,
- port: this.port,
- ca: this.tlsCA,
- };
- }
- if (!(connectOptions as tls.ConnectionOptions).host) {
- throw new Error("Hostname is not set");
- }
- if (!(connectOptions as tls.ConnectionOptions).port) {
- throw new Error("Port is not set");
- }
-
- return new Promise((resolve, reject) => {
- if (this.socket) {
- throw new Error("Sender connected already");
- }
-
- let authenticated: boolean = false;
- let data;
-
- this.socket = !this.secure
- ? net.connect(connectOptions as net.NetConnectOpts)
- : tls.connect(connectOptions, () => {
- if (authenticated) {
- resolve(true);
- }
- });
- this.socket.setKeepAlive(true);
-
- this.socket
- .on("data", async (raw) => {
- data = !data ? raw : Buffer.concat([data, raw]);
- if (!authenticated) {
- authenticated = await authenticate(this, data);
- if (authenticated) {
- resolve(true);
- }
- } else {
- this.log("warn", `Received unexpected data: ${data}`);
- }
- })
- .on("ready", async () => {
- this.log(
- "info",
- `Successfully connected to ${(connectOptions as tls.ConnectionOptions).host}:${(connectOptions as tls.ConnectionOptions).port}`,
- );
- if (this.jwk) {
- this.log(
- "info",
- `Authenticating with ${(connectOptions as tls.ConnectionOptions).host}:${(connectOptions as tls.ConnectionOptions).port}`,
- );
- await this.socket.write(`${this.jwk.kid}\n`, (err: Error) => {
- if (err) {
- reject(err);
- }
- });
- } else {
- authenticated = true;
- if (!this.secure || !this.tlsVerify) {
- resolve(true);
- }
- }
- })
- .on("error", (err) => {
- this.log("error", err);
- if (err.code !== "SELF_SIGNED_CERT_IN_CHAIN" || this.tlsVerify) {
- reject(err);
- }
- });
- });
- }
-
- /**
- * @ignore
- * @return {Agent} Returns the default http agent.
- */
- static getDefaultHttpAgent(): Agent {
- if (!Sender.DEFAULT_HTTP_AGENT) {
- Sender.DEFAULT_HTTP_AGENT = new Agent(DEFAULT_HTTP_OPTIONS);
- }
- return Sender.DEFAULT_HTTP_AGENT;
+ connect(): Promise {
+ return this.transport.connect();
}
/**
@@ -439,103 +204,12 @@ class Sender {
* @return {Promise} Resolves to true when there was data in the buffer to send, and it was sent successfully.
*/
async flush(): Promise {
- const dataToSend = this.toBufferNew(this.endOfLastRow);
+ const dataToSend: Buffer = this.toBufferNew();
if (!dataToSend) {
return false; // Nothing to send
}
- try {
- if (this.http) {
- const { timeout: calculatedTimeoutMillis } = createRequestOptions(this, dataToSend);
- const retryBegin = Date.now();
- const headers: Record = {};
-
- const dispatcher = new RetryAgent(this.agent, {
- maxRetries: Infinity,
- minTimeout: 10,
- maxTimeout: 1000,
- timeoutFactor: 2,
- retryAfter: true,
- methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
- statusCodes: RETRIABLE_STATUS_CODES,
- errorCodes: [
- "ECONNRESET",
- "EAI_AGAIN",
- "ECONNREFUSED",
- "ETIMEDOUT",
- "EPIPE",
- "UND_ERR_CONNECT_TIMEOUT",
- "UND_ERR_HEADERS_TIMEOUT",
- "UND_ERR_BODY_TIMEOUT",
- ],
- retry: (err, context, callback) => {
- const elapsed = Date.now() - retryBegin;
- if (elapsed > this.retryTimeout) {
- return callback(err);
- }
- return callback(null);
- },
- });
-
- if (this.token) {
- headers["Authorization"] = "Bearer " + this.token;
- } else if (this.username && this.password) {
- headers["Authorization"] =
- "Basic " +
- Buffer.from(this.username + ":" + this.password).toString("base64");
- }
-
- const { statusCode, body } = await dispatcher.request({
- origin: `${this.secure ? "https" : "http"}://${this.host}:${this.port}`,
- path: "/write?precision=n",
- method: "POST",
- headers,
- body: dataToSend,
- headersTimeout: this.requestTimeout,
- bodyTimeout: calculatedTimeoutMillis,
- });
-
- const responseBody = await body.arrayBuffer();
- if (statusCode === HTTP_NO_CONTENT) {
- if (responseBody.byteLength > 0) {
- this.log(
- "warn",
- `Unexpected message from server: ${Buffer.from(responseBody).toString()}`,
- );
- }
- return true;
- } else {
- throw new Error(
- `HTTP request failed, statusCode=${statusCode}, error=${Buffer.from(responseBody).toString()}`,
- );
- }
- } else { // TCP
- if (!this.socket || this.socket.destroyed) {
- throw new Error("Sender is not connected");
- }
- return new Promise((resolve, reject) => {
- this.socket.write(dataToSend, (err: Error) => { // Use the copied dataToSend
- if (err) {
- reject(err);
- } else {
- resolve(true);
- }
- });
- });
- }
- } catch (err) {
- // Log the error and then throw a new, standardized error
- if (this.http && err.code === "UND_ERR_HEADERS_TIMEOUT") {
- this.log("error", `HTTP request timeout, no response from server in time. Original error: ${err.message}`);
- throw new Error(`HTTP request timeout, statusCode=${err.statusCode}, error=${err.message}`);
- } else if (this.http) {
- this.log("error", `HTTP request failed, statusCode=${err.statusCode || 'unknown'}, error=${err.message}`);
- throw new Error(`HTTP request failed, statusCode=${err.statusCode || 'unknown'}, error=${err.message}`);
- } else { // TCP
- this.log("error", `TCP send failed: ${err.message}`);
- throw new Error(`TCP send failed, error=${err.message}`);
- }
- }
+ await this.transport.send(dataToSend);
}
/**
@@ -543,13 +217,7 @@ class Sender {
* Data sitting in the Sender's buffer will be lost unless flush() is called before close().
*/
async close() {
- if (this.socket) {
- const address = this.socket.remoteAddress;
- const port = this.socket.remotePort;
- this.socket.destroy();
- this.socket = null;
- this.log("info", `Connection to ${address}:${port} is closed`);
- }
+ return this.transport.close();
}
/**
@@ -558,7 +226,7 @@ class Sender {
* The returned buffer is backed by the sender's buffer.
* Used only in tests.
*/
- toBufferView(pos = this.position): Buffer {
+ toBufferView(pos = this.endOfLastRow): Buffer {
return pos > 0 ? this.buffer.subarray(0, pos) : null;
}
@@ -568,11 +236,11 @@ class Sender {
* The returned buffer is a copy of the sender's buffer.
* It also compacts the Sender's buffer.
*/
- toBufferNew(pos = this.position): Buffer | null {
+ toBufferNew(pos = this.endOfLastRow): Buffer | null {
if (pos > 0) {
const data = Buffer.allocUnsafe(pos);
this.buffer.copy(data, 0, 0, pos);
- compact(this);
+ this.compact();
return data;
}
return null;
@@ -592,8 +260,8 @@ class Sender {
throw new Error("Table name has already been set");
}
validateTableName(table, this.maxNameLength);
- checkCapacity(this, [table]);
- writeEscaped(this, table);
+ this.checkCapacity([table]);
+ this.writeEscaped(table);
this.hasTable = true;
return this;
}
@@ -610,17 +278,15 @@ class Sender {
throw new Error(`Symbol name must be a string, received ${typeof name}`);
}
if (!this.hasTable || this.hasColumns) {
- throw new Error(
- "Symbol can be added only after table name is set and before any column added",
- );
+ throw new Error("Symbol can be added only after table name is set and before any column added");
}
const valueStr = value.toString();
- checkCapacity(this, [name, valueStr], 2 + name.length + valueStr.length);
- write(this, ",");
+ this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length);
+ this.write(",");
validateColumnName(name, this.maxNameLength);
- writeEscaped(this, name);
- write(this, "=");
- writeEscaped(this, valueStr);
+ this.writeEscaped(name);
+ this.write("=");
+ this.writeEscaped(valueStr);
this.hasSymbols = true;
return this;
}
@@ -633,15 +299,14 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
stringColumn(name: string, value: string): Sender {
- writeColumn(
- this,
+ this.writeColumn(
name,
value,
() => {
- checkCapacity(this, [value], 2 + value.length);
- write(this, '"');
- writeEscaped(this, value, true);
- write(this, '"');
+ this.checkCapacity([value], 2 + value.length);
+ this.write('"');
+ this.writeEscaped(value, true);
+ this.write('"');
},
"string",
);
@@ -656,13 +321,12 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
booleanColumn(name: string, value: boolean): Sender {
- writeColumn(
- this,
+ this.writeColumn(
name,
value,
() => {
- checkCapacity(this, [], 1);
- write(this, value ? "t" : "f");
+ this.checkCapacity([], 1);
+ this.write(value ? "t" : "f");
},
"boolean",
);
@@ -677,14 +341,13 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
floatColumn(name: string, value: number): Sender {
- writeColumn(
- this,
+ this.writeColumn(
name,
value,
() => {
const valueStr = value.toString();
- checkCapacity(this, [valueStr], valueStr.length);
- write(this, valueStr);
+ this.checkCapacity([valueStr], valueStr.length);
+ this.write(valueStr);
},
"number",
);
@@ -702,11 +365,11 @@ class Sender {
if (!Number.isInteger(value)) {
throw new Error(`Value must be an integer, received ${value}`);
}
- writeColumn(this, name, value, () => {
+ this.writeColumn(name, value, () => {
const valueStr = value.toString();
- checkCapacity(this, [valueStr], 1 + valueStr.length);
- write(this, valueStr);
- write(this, "i");
+ this.checkCapacity([valueStr], 1 + valueStr.length);
+ this.write(valueStr);
+ this.write("i");
});
return this;
}
@@ -727,12 +390,12 @@ class Sender {
if (typeof value !== "bigint" && !Number.isInteger(value)) {
throw new Error(`Value must be an integer or BigInt, received ${value}`);
}
- writeColumn(this, name, value, () => {
+ this.writeColumn(name, value, () => {
const valueMicros = timestampToMicros(BigInt(value), unit);
const valueStr = valueMicros.toString();
- checkCapacity(this, [valueStr], 1 + valueStr.length);
- write(this, valueStr);
- write(this, "t");
+ this.checkCapacity([valueStr], 1 + valueStr.length);
+ this.write(valueStr);
+ this.write("t");
});
return this;
}
@@ -745,24 +408,20 @@ class Sender {
*/
async at(timestamp: number | bigint, unit: "ns" | "us" | "ms" = "us") {
if (!this.hasSymbols && !this.hasColumns) {
- throw new Error(
- "The row must have a symbol or column set before it is closed",
- );
+ throw new Error("The row must have a symbol or column set before it is closed");
}
if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
- throw new Error(
- `Designated timestamp must be an integer or BigInt, received ${timestamp}`,
- );
+ throw new Error(`Designated timestamp must be an integer or BigInt, received ${timestamp}`);
}
const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
const timestampStr = timestampNanos.toString();
- checkCapacity(this, [], 2 + timestampStr.length);
- write(this, " ");
- write(this, timestampStr);
- write(this, "\n");
+ this.checkCapacity([], 2 + timestampStr.length);
+ this.write(" ");
+ this.write(timestampStr);
+ this.write("\n");
this.pendingRowCount++;
- startNewRow(this);
- await autoFlush(this);
+ this.startNewRow();
+ await this.automaticFlush();
}
/**
@@ -771,310 +430,130 @@ class Sender {
*/
async atNow() {
if (!this.hasSymbols && !this.hasColumns) {
- throw new Error(
- "The row must have a symbol or column set before it is closed",
- );
+ throw new Error("The row must have a symbol or column set before it is closed");
}
- checkCapacity(this, [], 1);
- write(this, "\n");
+ this.checkCapacity([], 1);
+ this.write("\n");
this.pendingRowCount++;
- startNewRow(this);
- await autoFlush(this);
- }
-}
-
-function isBoolean(value: unknown): value is boolean {
- return typeof value === "boolean";
-}
-
-function isInteger(value: unknown, lowerBound: number): value is number {
- return (
- typeof value === "number" && Number.isInteger(value) && value >= lowerBound
- );
-}
-
-async function authenticate(
- sender: Sender,
- challenge: Buffer,
-): Promise {
- // Check for trailing \n which ends the challenge
- if (challenge.subarray(-1).readInt8() === 10) {
- const keyObject = crypto.createPrivateKey({
- key: sender.jwk,
- format: "jwk",
- });
- const signature = crypto.sign(
- "RSA-SHA256",
- challenge.subarray(0, challenge.length - 1),
- keyObject,
- );
-
- return new Promise((resolve, reject) => {
- sender.socket.write(
- `${Buffer.from(signature).toString("base64")}\n`,
- (err: Error) => {
- if (err) {
- reject(err);
- } else {
- resolve(true);
- }
- },
- );
- });
- }
- return false;
-}
-
-function startNewRow(sender: Sender) {
- sender.endOfLastRow = sender.position;
- sender.hasTable = false;
- sender.hasSymbols = false;
- sender.hasColumns = false;
-}
-
-type InternalHttpOptions = {
- hostname: string;
- port: number;
- agent: Agent;
- protocol: string;
- path: string;
- method: string;
- timeout: number;
-};
-function createRequestOptions(
- sender: Sender,
- data: Buffer,
-): InternalHttpOptions {
- const timeoutMillis =
- (data.length / sender.requestMinThroughput) * 1000 + sender.requestTimeout;
- return {
- hostname: sender.host,
- port: sender.port,
- agent: sender.agent,
- protocol: sender.secure ? "https" : "http",
- path: "/write?precision=n",
- method: "POST",
- timeout: timeoutMillis,
- };
-}
-
-async function autoFlush(sender: Sender) {
- if (
- sender.autoFlush &&
- sender.pendingRowCount > 0 &&
- ((sender.autoFlushRows > 0 &&
- sender.pendingRowCount >= sender.autoFlushRows) ||
- (sender.autoFlushInterval > 0 &&
- Date.now() - sender.lastFlushTime >= sender.autoFlushInterval))
- ) {
- await sender.flush();
- }
-}
-
-function checkCapacity(sender: Sender, data: string[], base = 0) {
- let length = base;
- for (const str of data) {
- length += Buffer.byteLength(str, "utf8");
- }
- if (sender.position + length > sender.bufferSize) {
- let newSize = sender.bufferSize;
- do {
- newSize += sender.bufferSize;
- } while (sender.position + length > newSize);
- sender.resize(newSize);
- }
-}
-
-function compact(sender: Sender) {
- if (sender.endOfLastRow > 0) {
- sender.buffer.copy(sender.buffer, 0, sender.endOfLastRow, sender.position);
- sender.position = sender.position - sender.endOfLastRow;
- sender.endOfLastRow = 0;
-
- sender.lastFlushTime = Date.now();
- sender.pendingRowCount = 0;
- }
-}
-
-function writeColumn(
- sender: Sender,
- name: string,
- value: unknown,
- writeValue: () => void,
- valueType?: string | null,
-) {
- if (typeof name !== "string") {
- throw new Error(`Column name must be a string, received ${typeof name}`);
- }
- if (valueType != null && typeof value !== valueType) {
- throw new Error(
- `Column value must be of type ${valueType}, received ${typeof value}`,
- );
- }
- if (!sender.hasTable) {
- throw new Error("Column can be set only after table name is set");
- }
- checkCapacity(sender, [name], 2 + name.length);
- write(sender, sender.hasColumns ? "," : " ");
- validateColumnName(name, sender.maxNameLength);
- writeEscaped(sender, name);
- write(sender, "=");
- writeValue();
- sender.hasColumns = true;
-}
-
-function write(sender: Sender, data: string) {
- sender.position += sender.buffer.write(data, sender.position);
- if (sender.position > sender.bufferSize) {
- throw new Error(
- `Buffer overflow [position=${sender.position}, bufferSize=${sender.bufferSize}]`,
- );
+ this.startNewRow();
+ await this.automaticFlush();
+ }
+
+ private startNewRow() {
+ this.endOfLastRow = this.position;
+ this.hasTable = false;
+ this.hasSymbols = false;
+ this.hasColumns = false;
+ }
+
+ private async automaticFlush() {
+ if (
+ this.autoFlush &&
+ this.pendingRowCount > 0 &&
+ ((this.autoFlushRows > 0 &&
+ this.pendingRowCount >= this.autoFlushRows) ||
+ (this.autoFlushInterval > 0 &&
+ Date.now() - this.lastFlushTime >= this.autoFlushInterval))
+ ) {
+ await this.flush();
+ }
}
-}
-function writeEscaped(sender: Sender, data: string, quoted = false) {
- for (const ch of data) {
- if (ch > "\\") {
- write(sender, ch);
- continue;
+ private checkCapacity(data: string[], base = 0) {
+ let length = base;
+ for (const str of data) {
+ length += Buffer.byteLength(str, "utf8");
}
-
- switch (ch) {
- case " ":
- case ",":
- case "=":
- if (!quoted) {
- write(sender, "\\");
- }
- write(sender, ch);
- break;
- case "\n":
- case "\r":
- write(sender, "\\");
- write(sender, ch);
- break;
- case '"':
- if (quoted) {
- write(sender, "\\");
- }
- write(sender, ch);
- break;
- case "\\":
- write(sender, "\\\\");
- break;
- default:
- write(sender, ch);
- break;
+ if (this.position + length > this.bufferSize) {
+ let newSize = this.bufferSize;
+ do {
+ newSize += this.bufferSize;
+ } while (this.position + length > newSize);
+ this.resize(newSize);
}
}
-}
-
-function timestampToMicros(timestamp: bigint, unit: "ns" | "us" | "ms") {
- switch (unit) {
- case "ns":
- return timestamp / 1000n;
- case "us":
- return timestamp;
- case "ms":
- return timestamp * 1000n;
- default:
- throw new Error("Unknown timestamp unit: " + unit);
- }
-}
-
-function timestampToNanos(timestamp: bigint, unit: "ns" | "us" | "ms") {
- switch (unit) {
- case "ns":
- return timestamp;
- case "us":
- return timestamp * 1000n;
- case "ms":
- return timestamp * 1000_000n;
- default:
- throw new Error("Unknown timestamp unit: " + unit);
- }
-}
-type DeprecatedOptions = {
- /** @deprecated */
- copy_buffer?: boolean;
- /** @deprecated */
- copyBuffer?: boolean;
- /** @deprecated */
- bufferSize?: number;
-};
-function replaceDeprecatedOptions(
- options: SenderOptions & DeprecatedOptions,
- log: (level: "error" | "warn" | "info" | "debug", message: string) => void
-) {
- // deal with deprecated options
- if (options.copy_buffer !== undefined) {
- log("warn", `Option 'copy_buffer' is not supported anymore, please, remove it`);
- }
- if (options.copyBuffer !== undefined) {
- log("warn", `Option 'copyBuffer' is not supported anymore, please, remove it`);
- }
- if (options.bufferSize !== undefined) {
- log("warn", `Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'`);
- options.init_buf_size = options.bufferSize;
- options.bufferSize = undefined;
- }
-}
+ private compact() {
+ if (this.endOfLastRow > 0) {
+ this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
+ this.position = this.position - this.endOfLastRow;
+ this.endOfLastRow = 0;
-function constructAuth(options: SenderOptions) {
- if (!options.username && !options.token && !options.password) {
- // no intention to authenticate
- return;
- }
- if (!options.username || !options.token) {
- throw new Error(
- "TCP transport requires a username and a private key for authentication, " +
- "please, specify the 'username' and 'token' config options",
- );
+ this.lastFlushTime = Date.now();
+ this.pendingRowCount = 0;
+ }
}
- options.auth = {
- keyId: options.username,
- token: options.token,
- };
-}
-
-function constructJwk(options: SenderOptions) {
- if (options.auth) {
- if (!options.auth.keyId) {
- throw new Error(
- "Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
+ private writeColumn(
+ name: string,
+ value: unknown,
+ writeValue: () => void,
+ valueType?: string | null,
+ ) {
+ if (typeof name !== "string") {
+ throw new Error(`Column name must be a string, received ${typeof name}`);
}
- if (typeof options.auth.keyId !== "string") {
+ if (valueType != null && typeof value !== valueType) {
throw new Error(
- "Please, specify the 'keyId' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ `Column value must be of type ${valueType}, received ${typeof value}`,
);
}
- if (!options.auth.token) {
- throw new Error(
- "Missing private key, please, specify the 'token' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
+ if (!this.hasTable) {
+ throw new Error("Column can be set only after table name is set");
}
- if (typeof options.auth.token !== "string") {
+ this.checkCapacity([name], 2 + name.length);
+ this.write(this.hasColumns ? "," : " ");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ writeValue();
+ this.hasColumns = true;
+ }
+
+ private write(data: string) {
+ this.position += this.buffer.write(data, this.position);
+ if (this.position > this.bufferSize) {
throw new Error(
- "Please, specify the 'token' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
);
}
+ }
+
+ private writeEscaped(data: string, quoted = false) {
+ for (const ch of data) {
+ if (ch > "\\") {
+ this.write(ch);
+ continue;
+ }
- return {
- kid: options.auth.keyId,
- d: options.auth.token,
- ...PUBLIC_KEY,
- kty: "EC",
- crv: "P-256",
- };
- } else {
- return options.jwk;
+ switch (ch) {
+ case " ":
+ case ",":
+ case "=":
+ if (!quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\n":
+ case "\r":
+ this.write("\\");
+ this.write(ch);
+ break;
+ case '"':
+ if (quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\\":
+ this.write("\\\\");
+ break;
+ default:
+ this.write(ch);
+ break;
+ }
+ }
}
}
diff --git a/src/transport/http/base.ts b/src/transport/http/base.ts
new file mode 100644
index 0000000..0d42b61
--- /dev/null
+++ b/src/transport/http/base.ts
@@ -0,0 +1,114 @@
+// @ts-check
+import { readFileSync } from "node:fs";
+import { Buffer } from "node:buffer";
+
+import { log, Logger } from "../../logging";
+import { SenderOptions, HTTP, HTTPS } from "../../options";
+import { SenderTransport } from "../index";
+import { isBoolean, isInteger } from "../../utils";
+
+const HTTP_NO_CONTENT = 204; // success
+
+const DEFAULT_HTTP_AUTO_FLUSH_ROWS = 75000;
+
+const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400; // 100 KB/sec
+const DEFAULT_REQUEST_TIMEOUT = 10000; // 10 sec
+const DEFAULT_RETRY_TIMEOUT = 10000; // 10 sec
+
+/*
+We are retrying on the following response codes (copied from the Rust client):
+500: Internal Server Error
+503: Service Unavailable
+504: Gateway Timeout
+
+// Unofficial extensions
+507: Insufficient Storage
+509: Bandwidth Limit Exceeded
+523: Origin is Unreachable
+524: A Timeout Occurred
+529: Site is overloaded
+599: Network Connect Timeout Error
+*/
+const RETRIABLE_STATUS_CODES = [500, 503, 504, 507, 509, 523, 524, 529, 599];
+
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
+ */
+abstract class HttpTransportBase implements SenderTransport {
+ protected readonly secure: boolean;
+ protected readonly host: string;
+ protected readonly port: number;
+
+ protected readonly username: string;
+ protected readonly password: string;
+ protected readonly token: string;
+
+ protected readonly tlsVerify: boolean;
+ protected readonly tlsCA: Buffer;
+
+ protected readonly requestMinThroughput: number;
+ protected readonly requestTimeout: number;
+ protected readonly retryTimeout: number;
+
+ protected readonly log: Logger;
+
+ protected constructor(options: SenderOptions) {
+ if (!options || !options.protocol) {
+ throw new Error("The 'protocol' option is mandatory");
+ }
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
+ this.log = typeof options.log === "function" ? options.log : log;
+
+ this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
+ this.tlsCA = options.tls_ca ? readFileSync(options.tls_ca) : undefined;
+
+ this.username = options.username;
+ this.password = options.password;
+ this.token = options.token;
+ if (!options.port) {
+ options.port = 9000;
+ }
+
+ this.host = options.host;
+ this.port = options.port;
+
+ this.requestMinThroughput = isInteger(options.request_min_throughput, 0)
+ ? options.request_min_throughput
+ : DEFAULT_REQUEST_MIN_THROUGHPUT;
+ this.requestTimeout = isInteger(options.request_timeout, 1)
+ ? options.request_timeout
+ : DEFAULT_REQUEST_TIMEOUT;
+ this.retryTimeout = isInteger(options.retry_timeout, 0)
+ ? options.retry_timeout
+ : DEFAULT_RETRY_TIMEOUT;
+
+ switch (options.protocol) {
+ case HTTP:
+ this.secure = false;
+ break;
+ case HTTPS:
+ this.secure = true;
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
+ }
+ }
+
+ connect(): Promise {
+ throw new Error("'connect()' is not required for HTTP transport");
+ }
+
+ async close(): Promise {
+ }
+
+ getDefaultAutoFlushRows(): number {
+ return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
+ }
+
+ abstract send(data: Buffer): Promise;
+}
+
+export { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT };
diff --git a/src/transport/http/legacy.ts b/src/transport/http/legacy.ts
new file mode 100644
index 0000000..d62852f
--- /dev/null
+++ b/src/transport/http/legacy.ts
@@ -0,0 +1,165 @@
+// @ts-check
+import http from "http";
+import https from "https";
+import { Buffer } from "node:buffer";
+
+import { SenderOptions, HTTP, HTTPS } from "../../options";
+import { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT } from "./base";
+
+// default options for HTTP agent
+// - persistent connections with 1 minute idle timeout, server side has 5 minutes set by default
+// - max open connections is set to 256, same as server side default
+const DEFAULT_HTTP_AGENT_CONFIG = {
+ maxSockets: 256,
+ keepAlive: true,
+ timeout: 60000 // 1 min
+}
+
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
+ * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
+ */
+class HttpTransport extends HttpTransportBase {
+ private static DEFAULT_HTTP_AGENT: http.Agent;
+ private static DEFAULT_HTTPS_AGENT: https.Agent;
+
+ private readonly agent: http.Agent | https.Agent;
+
+ /**
+ * Creates an instance of Sender.
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
+ */
+ constructor(options: SenderOptions) {
+ super(options);
+
+ switch (options.protocol) {
+ case HTTP:
+ this.agent = options.agent instanceof http.Agent ? options.agent : HttpTransport.getDefaultHttpAgent();
+ break;
+ case HTTPS:
+ this.agent = options.agent instanceof https.Agent ? options.agent : HttpTransport.getDefaultHttpsAgent();
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
+ }
+ }
+
+ send(data: Buffer, retryBegin = -1, retryInterval = -1): Promise {
+ const request = this.secure ? https.request : http.request;
+
+ const timeoutMillis = (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
+ const options = this.createRequestOptions(timeoutMillis);
+
+ return new Promise((resolve, reject) => {
+ let statusCode = -1;
+ const req = request(options, response => {
+ statusCode = response.statusCode;
+
+ const body = [];
+ response
+ .on("data", chunk => {
+ body.push(chunk);
+ })
+ .on("error", err => {
+ this.log("error", `resp err=${err}`);
+ });
+
+ if (statusCode === HTTP_NO_CONTENT) {
+ response.on("end", () => {
+ if (body.length > 0) {
+ this.log("warn", `Unexpected message from server: ${Buffer.concat(body)}`);
+ }
+ resolve(true);
+ });
+ } else {
+ req.destroy(new Error(`HTTP request failed, statusCode=${statusCode}, error=${Buffer.concat(body)}`));
+ }
+ });
+
+ if (this.token) {
+ req.setHeader("Authorization", `Bearer ${this.token}`);
+ } else if (this.username && this.password) {
+ req.setHeader("Authorization", `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`);
+ }
+
+ req.on("timeout", () => {
+ // set a retryable error code
+ statusCode = 524;
+ req.destroy(new Error("HTTP request timeout, no response from server in time"));
+ });
+ req.on("error", err => {
+ // if the error is thrown while the request is sent, statusCode is -1 => no retry
+ // request timeout comes through with statusCode 524 => retry
+ // if the error is thrown while the response is processed, the statusCode is taken from the response => retry depends on statusCode
+ if (isRetryable(statusCode) && this.retryTimeout > 0) {
+ if (retryBegin < 0) {
+ retryBegin = Date.now();
+ retryInterval = 10;
+ } else {
+ const elapsed = Date.now() - retryBegin;
+ if (elapsed > this.retryTimeout) {
+ reject(err);
+ return;
+ }
+ }
+ const jitter = Math.floor(Math.random() * 10) - 5;
+ setTimeout(() => {
+ retryInterval = Math.min(retryInterval * 2, 1000);
+ this.send(data, retryBegin, retryInterval)
+ .then(() => resolve(true))
+ .catch(e => reject(e));
+ }, retryInterval + jitter);
+ } else {
+ reject(err);
+ }
+ });
+ req.write(data, err => err ? reject(err) : () => {});
+ req.end();
+ });
+ }
+
+ private createRequestOptions(timeoutMillis: number): http.RequestOptions | https.RequestOptions {
+ return {
+ //protocol: this.secure ? "https:" : "http:",
+ hostname: this.host,
+ port: this.port,
+ agent: this.agent,
+ path: "/write?precision=n",
+ method: "POST",
+ timeout: timeoutMillis,
+ rejectUnauthorized: this.secure && this.tlsVerify,
+ ca: this.secure ? this.tlsCA : undefined,
+ };
+ }
+
+ /**
+ * @ignore
+ * @return {http.Agent} Returns the default http agent.
+ */
+ private static getDefaultHttpAgent(): http.Agent {
+ if (!HttpTransport.DEFAULT_HTTP_AGENT) {
+ HttpTransport.DEFAULT_HTTP_AGENT = new http.Agent(DEFAULT_HTTP_AGENT_CONFIG);
+ }
+ return HttpTransport.DEFAULT_HTTP_AGENT;
+ }
+
+ /**
+ * @ignore
+ * @return {https.Agent} Returns the default https agent.
+ */
+ private static getDefaultHttpsAgent(): https.Agent {
+ if (!HttpTransport.DEFAULT_HTTPS_AGENT) {
+ HttpTransport.DEFAULT_HTTPS_AGENT = new https.Agent(DEFAULT_HTTP_AGENT_CONFIG);
+ }
+ return HttpTransport.DEFAULT_HTTPS_AGENT;
+ }
+}
+
+function isRetryable(statusCode: number) {
+ return RETRIABLE_STATUS_CODES.includes(statusCode);
+}
+
+export { HttpTransport, HttpTransportBase };
diff --git a/src/transport/http/undici.ts b/src/transport/http/undici.ts
new file mode 100644
index 0000000..43575fa
--- /dev/null
+++ b/src/transport/http/undici.ts
@@ -0,0 +1,143 @@
+// @ts-check
+import { Buffer } from "node:buffer";
+import { Agent, RetryAgent } from "undici";
+import Dispatcher from "undici/types/dispatcher";
+
+import { SenderOptions, HTTP, HTTPS } from "../../options";
+import { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT } from "./base";
+
+const DEFAULT_HTTP_OPTIONS: Agent.Options = {
+ connect: {
+ keepAlive: true,
+ },
+ pipelining: 1,
+ keepAliveTimeout: 60000, // 1 minute
+};
+
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
+ * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
+ *
+ */
+class UndiciTransport extends HttpTransportBase {
+ private static DEFAULT_HTTP_AGENT: Agent;
+
+ private readonly agent: Dispatcher;
+ private readonly dispatcher : RetryAgent;
+
+ /**
+ * Creates an instance of Sender.
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
+ */
+ constructor(options: SenderOptions) {
+ super(options);
+
+ switch (options.protocol) {
+ case HTTP:
+ this.agent = options.agent instanceof Agent ? options.agent : UndiciTransport.getDefaultHttpAgent();
+ break;
+ case HTTPS:
+ if (options.agent instanceof Agent) {
+ this.agent = options.agent;
+ } else {
+ // Create a new agent with instance-specific TLS options
+ this.agent = new Agent({
+ ...DEFAULT_HTTP_OPTIONS,
+ connect: {
+ ...DEFAULT_HTTP_OPTIONS.connect,
+ requestCert: this.tlsVerify,
+ rejectUnauthorized: this.tlsVerify,
+ ca: this.tlsCA,
+ },
+ });
+ }
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the Undici HTTP transport");
+ }
+
+ this.dispatcher = new RetryAgent(this.agent, {
+ maxRetries: Infinity,
+ minTimeout: 10,
+ maxTimeout: 1000,
+ timeoutFactor: 2,
+ retryAfter: true,
+ methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
+ statusCodes: RETRIABLE_STATUS_CODES,
+ errorCodes: [
+ "ECONNRESET",
+ "EAI_AGAIN",
+ "ECONNREFUSED",
+ "ETIMEDOUT",
+ "EPIPE",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_BODY_TIMEOUT",
+ ],
+ });
+ }
+
+ async send(data: Buffer): Promise {
+ const headers: Record = {};
+ if (this.token) {
+ headers["Authorization"] = `Bearer ${this.token}`;
+ } else if (this.username && this.password) {
+ headers["Authorization"] = `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`;
+ }
+
+ const controller = new AbortController();
+ const { signal } = controller;
+ setTimeout(() => controller.abort(), this.retryTimeout);
+
+ let responseData: Dispatcher.ResponseData;
+ try {
+ const timeoutMillis = (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
+ responseData =
+ await this.dispatcher.request({
+ origin: `${this.secure ? "https" : "http"}://${this.host}:${this.port}`,
+ path: "/write?precision=n",
+ method: "POST",
+ headers,
+ body: data,
+ headersTimeout: this.requestTimeout,
+ bodyTimeout: timeoutMillis,
+ signal,
+ });
+ } catch (err) {
+ if (err.name === "AbortError") {
+ throw new Error("HTTP request timeout, no response from server in time");
+ } else {
+ throw err;
+ }
+ }
+
+ const { statusCode} = responseData;
+ const body = await responseData.body.arrayBuffer();
+ if (statusCode === HTTP_NO_CONTENT) {
+ if (body.byteLength > 0) {
+ this.log("warn", `Unexpected message from server: ${Buffer.from(body).toString()}`);
+ }
+ return true;
+ } else {
+ throw new Error(
+ `HTTP request failed, statusCode=${statusCode}, error=${Buffer.from(body).toString()}`,
+ );
+ }
+ }
+
+ /**
+ * @ignore
+ * @return {Agent} Returns the default http agent.
+ */
+ private static getDefaultHttpAgent(): Agent {
+ if (!UndiciTransport.DEFAULT_HTTP_AGENT) {
+ UndiciTransport.DEFAULT_HTTP_AGENT = new Agent(DEFAULT_HTTP_OPTIONS);
+ }
+ return UndiciTransport.DEFAULT_HTTP_AGENT;
+ }
+}
+
+export { UndiciTransport };
diff --git a/src/transport/index.ts b/src/transport/index.ts
new file mode 100644
index 0000000..983af2c
--- /dev/null
+++ b/src/transport/index.ts
@@ -0,0 +1,36 @@
+// @ts-check
+import { Buffer } from "node:buffer";
+
+import { SenderOptions, HTTP, HTTPS, TCP, TCPS } from "../options";
+import { UndiciTransport } from "./http/undici";
+import { TcpTransport } from "./tcp";
+import { HttpTransport } from "./http/legacy";
+
+interface SenderTransport {
+ connect(): Promise;
+ send(data: Buffer): Promise;
+ close(): Promise;
+ getDefaultAutoFlushRows(): number;
+}
+
+function createTransport(options: SenderOptions): SenderTransport {
+ if (!options || !options.protocol) {
+ throw new Error("The 'protocol' option is mandatory");
+ }
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
+
+ switch (options.protocol) {
+ case HTTP:
+ case HTTPS:
+ return options.legacy_http ? new HttpTransport(options) : new UndiciTransport(options);
+ case TCP:
+ case TCPS:
+ return new TcpTransport(options);
+ default:
+ throw new Error(`Invalid protocol: '${options.protocol}'`);
+ }
+}
+
+export { SenderTransport, createTransport }
diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts
new file mode 100644
index 0000000..be6cf05
--- /dev/null
+++ b/src/transport/tcp.ts
@@ -0,0 +1,302 @@
+// @ts-check
+import { readFileSync } from "node:fs";
+import { Buffer } from "node:buffer";
+import net from "node:net";
+import tls from "node:tls";
+import crypto from "node:crypto";
+
+import { log, Logger } from "../logging";
+import { SenderOptions, TCP, TCPS } from "../options";
+import { SenderTransport } from "./index";
+import { isBoolean } from "../utils";
+
+const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600;
+
+// an arbitrary public key, not used in authentication
+// only used to construct a valid JWK token which is accepted by the crypto API
+const PUBLIC_KEY = {
+ x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc",
+ y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg",
+};
+
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
+ * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
+ *
+ * The client supports authentication.
+ * Authentication details can be passed to the Sender in its configuration options.
+ * The client supports Basic username/password and Bearer token authentication methods when used with HTTP protocol,
+ * and JWK token authentication when ingesting data via TCP.
+ * Please, note that authentication is enabled by default in QuestDB Enterprise only.
+ * Details on how to configure authentication in the open source version of
+ * QuestDB: {@link https://questdb.io/docs/reference/api/ilp/authenticate}
+ *
+ *
+ * The client also supports TLS encryption for both, HTTP and TCP transports to provide a secure connection.
+ * Please, note that the open source version of QuestDB does not support TLS, and requires an external reverse-proxy,
+ * such as Nginx to enable encryption.
+ *
+ *
+ * The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server.
+ * Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum
+ * buffer sizes can also be set.
+ *
+ *
+ * It is recommended that the Sender is created by using one of the static factory methods,
+ * Sender.fromConfig(configString, extraOptions) or Sender.fromEnv(extraOptions).
+ * If the Sender is created via its constructor, at least the SenderOptions configuration object should be
+ * initialized from a configuration string to make sure that the parameters are validated.
+ * Detailed description of the Sender's configuration options can be found in
+ * the SenderOptions documentation.
+ *
+ *
+ * Extra options can be provided to the Sender in the extraOptions configuration object.
+ * A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object.
+ * The logger implementation provides the option to direct log messages to the same place where the host application's
+ * log is saved. The default logger writes to the console.
+ * The custom HTTP(S) agent option becomes handy if there is a need to modify the default options set for the
+ * HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be
+ * passed to the Sender with keepAlive set to false.
+ * For example: Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})
+ * If no custom agent is configured, the Sender will use its own agent which overrides some default values
+ * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
+ *
+ */
+class TcpTransport implements SenderTransport {
+ private readonly secure: boolean;
+ private readonly host: string;
+ private readonly port: number;
+
+ private socket: net.Socket | tls.TLSSocket;
+
+ private readonly tlsVerify: boolean;
+ private readonly tlsCA: Buffer;
+
+ private readonly log: Logger;
+ private readonly jwk: Record;
+
+ /**
+ * Creates an instance of Sender.
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
+ */
+ constructor(options: SenderOptions) {
+ if (!options || !options.protocol) {
+ throw new Error("The 'protocol' option is mandatory");
+ }
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
+ this.log = typeof options.log === "function" ? options.log : log;
+
+ this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
+ this.tlsCA = options.tls_ca ? readFileSync(options.tls_ca) : undefined;
+
+ this.host = options.host;
+ this.port = options.port;
+
+ switch (options.protocol) {
+ case TCP:
+ this.secure = false;
+ break;
+ case TCPS:
+ this.secure = true;
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'tcp' or 'tcps' for the TCP transport");
+ }
+
+ if (!options.auth && !options.jwk) {
+ constructAuth(options);
+ }
+ this.jwk = constructJwk(options);
+ if (!options.port) {
+ options.port = 9009;
+ }
+ }
+
+ /**
+ * Creates a TCP connection to the database.
+ *
+ * @return {Promise} Resolves to true if the client is connected.
+ */
+ connect(): Promise {
+ const connOptions: net.NetConnectOpts | tls.ConnectionOptions = {
+ host: this.host,
+ port: this.port,
+ ca: this.tlsCA,
+ };
+
+ return new Promise((resolve, reject) => {
+ if (this.socket) {
+ throw new Error("Sender connected already");
+ }
+
+ let authenticated: boolean = false;
+ let data: Buffer;
+
+ this.socket = !this.secure
+ ? net.connect(connOptions as net.NetConnectOpts)
+ : tls.connect(connOptions as tls.ConnectionOptions, () => {
+ if (authenticated) {
+ resolve(true);
+ }
+ });
+ this.socket.setKeepAlive(true);
+
+ this.socket
+ .on("data", async (raw) => {
+ data = !data ? raw : Buffer.concat([data, raw]);
+ if (!authenticated) {
+ authenticated = await this.authenticate(data);
+ if (authenticated) {
+ resolve(true);
+ }
+ } else {
+ this.log("warn", `Received unexpected data: ${data}`);
+ }
+ })
+ .on("ready", async () => {
+ this.log("info", `Successfully connected to ${connOptions.host}:${connOptions.port}`);
+ if (this.jwk) {
+ this.log("info", `Authenticating with ${connOptions.host}:${connOptions.port}`);
+ this.socket.write(`${this.jwk.kid}\n`, err => err ? reject(err) : () => {});
+ } else {
+ authenticated = true;
+ if (!this.secure || !this.tlsVerify) {
+ resolve(true);
+ }
+ }
+ })
+ .on("error", (err: Error & { code: string }) => {
+ this.log("error", err);
+ if (this.tlsVerify || !err.code || err.code !== "SELF_SIGNED_CERT_IN_CHAIN") {
+ reject(err);
+ }
+ });
+ });
+ }
+
+ send(data: Buffer): Promise {
+ if (!this.socket || this.socket.destroyed) {
+ throw new Error("TCP transport is not connected");
+ }
+ return new Promise((resolve, reject) => {
+ this.socket.write(data, (err: Error) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(true);
+ }
+ });
+ });
+ }
+
+ /**
+ * Closes the TCP connection to the database.
+ * Data sitting in the Sender's buffer will be lost unless flush() is called before close().
+ */
+ async close(): Promise {
+ if (this.socket) {
+ const address = this.socket.remoteAddress;
+ const port = this.socket.remotePort;
+ this.socket.destroy();
+ this.socket = null;
+ this.log("info", `Connection to ${address}:${port} is closed`);
+ }
+ }
+
+ getDefaultAutoFlushRows(): number {
+ return DEFAULT_TCP_AUTO_FLUSH_ROWS;
+ }
+
+ private async authenticate(challenge: Buffer): Promise {
+ // Check for trailing \n which ends the challenge
+ if (challenge.subarray(-1).readInt8() === 10) {
+ const keyObject = crypto.createPrivateKey({
+ key: this.jwk,
+ format: "jwk"
+ });
+ const signature = crypto.sign(
+ "RSA-SHA256",
+ challenge.subarray(0, challenge.length - 1),
+ keyObject
+ );
+
+ return new Promise((resolve, reject) => {
+ this.socket.write(
+ `${Buffer.from(signature).toString("base64")}\n`,
+ (err: Error) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(true);
+ }
+ }
+ );
+ });
+ }
+ return false;
+ }
+}
+
+function constructAuth(options: SenderOptions) {
+ if (!options.username && !options.token && !options.password) {
+ // no intention to authenticate
+ return;
+ }
+ if (!options.username || !options.token) {
+ throw new Error(
+ "TCP transport requires a username and a private key for authentication, " +
+ "please, specify the 'username' and 'token' config options",
+ );
+ }
+
+ options.auth = {
+ keyId: options.username,
+ token: options.token,
+ };
+}
+
+function constructJwk(options: SenderOptions) {
+ if (options.auth) {
+ if (!options.auth.keyId) {
+ throw new Error(
+ "Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ }
+ if (typeof options.auth.keyId !== "string") {
+ throw new Error(
+ "Please, specify the 'keyId' property of the 'auth' config option as a string. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ }
+ if (!options.auth.token) {
+ throw new Error(
+ "Missing private key, please, specify the 'token' property of the 'auth' config option. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ }
+ if (typeof options.auth.token !== "string") {
+ throw new Error(
+ "Please, specify the 'token' property of the 'auth' config option as a string. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ }
+
+ return {
+ kid: options.auth.keyId,
+ d: options.auth.token,
+ ...PUBLIC_KEY,
+ kty: "EC",
+ crv: "P-256",
+ };
+ } else {
+ return options.jwk;
+ }
+}
+
+export { TcpTransport };
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..dd82db6
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,37 @@
+function isBoolean(value: unknown): value is boolean {
+ return typeof value === "boolean";
+}
+
+function isInteger(value: unknown, lowerBound: number): value is number {
+ return (
+ typeof value === "number" && Number.isInteger(value) && value >= lowerBound
+ );
+}
+
+function timestampToMicros(timestamp: bigint, unit: "ns" | "us" | "ms") {
+ switch (unit) {
+ case "ns":
+ return timestamp / 1000n;
+ case "us":
+ return timestamp;
+ case "ms":
+ return timestamp * 1000n;
+ default:
+ throw new Error("Unknown timestamp unit: " + unit);
+ }
+}
+
+function timestampToNanos(timestamp: bigint, unit: "ns" | "us" | "ms") {
+ switch (unit) {
+ case "ns":
+ return timestamp;
+ case "us":
+ return timestamp * 1000n;
+ case "ms":
+ return timestamp * 1000_000n;
+ default:
+ throw new Error("Unknown timestamp unit: " + unit);
+ }
+}
+
+export { isBoolean, isInteger, timestampToMicros, timestampToNanos };
diff --git a/test/logging.test.ts b/test/logging.test.ts
index 4384e60..5997b78 100644
--- a/test/logging.test.ts
+++ b/test/logging.test.ts
@@ -1,22 +1,14 @@
-import {
- describe,
- it,
- beforeAll,
- afterAll,
- afterEach,
- expect,
- vi,
-} from "vitest";
+// @ts-check
+import { describe, it, beforeAll, afterAll, afterEach, expect, vi, } from "vitest";
+
+import { Logger } from "../src/logging";
describe("Default logging suite", function () {
const error = vi.spyOn(console, "error").mockImplementation(() => { });
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
const info = vi.spyOn(console, "info").mockImplementation(() => { });
const debug = vi.spyOn(console, "debug").mockImplementation(() => { });
- let log: (
- level: "error" | "warn" | "info" | "debug",
- message: string,
- ) => void;
+ let log: Logger;
beforeAll(async () => {
log = (await import("../src/logging")).log;
@@ -66,7 +58,7 @@ describe("Default logging suite", function () {
expect(info).toHaveBeenCalledWith(testMessage);
});
- it("cannot log debug level messages", function () {
+ it("cannot log debug level messages, default logging level is 'info'", function () {
const testMessage = "DEBUG DEBUG DEBUG";
log("debug", testMessage);
expect(error).toHaveBeenCalledTimes(0);
diff --git a/test/options.test.ts b/test/options.test.ts
index 576670c..e8be4e5 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -1,7 +1,9 @@
+// @ts-check
import { describe, it, expect } from "vitest";
-import { SenderOptions } from "../src/options";
import { Agent } from "undici";
+import { SenderOptions } from "../src/options";
+
describe("Configuration string parser suite", function () {
it("can parse a basic config string", function () {
const options = SenderOptions.fromConfig(
@@ -688,14 +690,14 @@ describe("Configuration string parser suite", function () {
// @ts-expect-error - Testing invalid input
agent: { keepAlive: true },
}),
- ).toThrow("Invalid http/https agent");
+ ).toThrow("Invalid HTTP agent");
expect(() =>
// @ts-expect-error - Testing invalid input
SenderOptions.fromConfig("http::addr=host:9000", { agent: 4567 }),
- ).toThrow("Invalid http/https agent");
+ ).toThrow("Invalid HTTP agent");
expect(() =>
// @ts-expect-error - Testing invalid input
SenderOptions.fromConfig("http::addr=host:9000", { agent: "hopp" }),
- ).toThrow("Invalid http/https agent");
+ ).toThrow("Invalid HTTP agent");
});
});
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
new file mode 100644
index 0000000..9e7c745
--- /dev/null
+++ b/test/sender.buffer.test.ts
@@ -0,0 +1,803 @@
+// @ts-check
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "fs";
+
+import { Sender } from "../src";
+
+describe("Client interop test suite", function () {
+ it("runs client tests as per json test config", async function () {
+ const testCases = JSON.parse(
+ readFileSync("./questdb-client-test/ilp-client-interop-test.json").toString()
+ );
+
+ for (const testCase of testCases) {
+ console.info(`test name: ${testCase.testName}`);
+
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ auto_flush: false,
+ init_buf_size: 1024,
+ });
+
+ let errorMessage: string;
+ try {
+ sender.table(testCase.table);
+ for (const symbol of testCase.symbols) {
+ sender.symbol(symbol.name, symbol.value);
+ }
+ for (const column of testCase.columns) {
+ switch (column.type) {
+ case "STRING":
+ sender.stringColumn(column.name, column.value);
+ break;
+ case "LONG":
+ sender.intColumn(column.name, column.value);
+ break;
+ case "DOUBLE":
+ sender.floatColumn(column.name, column.value);
+ break;
+ case "BOOLEAN":
+ sender.booleanColumn(column.name, column.value);
+ break;
+ case "TIMESTAMP":
+ sender.timestampColumn(column.name, column.value);
+ break;
+ default:
+ errorMessage = "Unsupported column type";
+ }
+ if (errorMessage) {
+ break;
+ }
+ }
+ await sender.atNow();
+ } catch (e) {
+ if (testCase.result.status === "ERROR") {
+ // error is expected, continue to next test case
+ continue;
+ }
+ errorMessage = `Unexpected error: ${e.message}`;
+ }
+
+ if (!errorMessage) {
+ const actualLine = sender.toBufferView().toString();
+
+ if (testCase.result.status === "SUCCESS") {
+ if (testCase.result.line) {
+ expect(actualLine).toBe(testCase.result.line + "\n");
+ } else {
+ let foundMatch = false;
+ for (const expectedLine of testCase.result.anyLines) {
+ if (actualLine === expectedLine + "\n") {
+ foundMatch = true;
+ break;
+ }
+ }
+ if (!foundMatch) {
+ errorMessage = `Line is not matching any of the expected results: ${actualLine}`;
+ }
+ }
+ } else {
+ errorMessage = `Expected error missing, buffer's content: ${actualLine}`;
+ }
+ }
+
+ await sender.close();
+ expect(errorMessage).toBeUndefined();
+ }
+ });
+});
+
+describe("Sender message builder test suite (anything not covered in client interop test suite)", function () {
+ it("throws on invalid timestamp unit", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ auto_flush: false,
+ init_buf_size: 1024,
+ });
+
+ await expect(async () =>
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ // @ts-expect-error - Testing invalid options
+ .timestampColumn("timestampCol", 1658484765000000, "foobar")
+ .atNow()
+ ).rejects.toThrow("Unknown timestamp unit: foobar");
+ await sender.close();
+ });
+
+ it("supports json object", async function () {
+ const pages: Array<{
+ id: string;
+ gridId: string;
+ }>[] = [];
+ for (let i = 0; i < 4; i++) {
+ const pageProducts: Array<{
+ id: string;
+ gridId: string;
+ }> = [
+ {
+ id: "46022e96-076f-457f-b630-51b82b871618" + i,
+ gridId: "46022e96-076f-457f-b630-51b82b871618",
+ },
+ {
+ id: "55615358-4af1-4179-9153-faaa57d71e55",
+ gridId: "55615358-4af1-4179-9153-faaa57d71e55",
+ },
+ {
+ id: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
+ gridId: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
+ },
+ {
+ id: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280",
+ gridId: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280" + i,
+ },
+ ];
+ pages.push(pageProducts);
+ }
+
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 256,
+ });
+ for (const p of pages) {
+ await sender
+ .table("tableName")
+ .stringColumn("page_products", JSON.stringify(p || []))
+ .booleanColumn("boolCol", true)
+ .atNow();
+ }
+ expect(sender.toBufferView().toString()).toBe(
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716180\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2800\\"}]",boolCol=t\n' +
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716181\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2801\\"}]",boolCol=t\n' +
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716182\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2802\\"}]",boolCol=t\n' +
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716183\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2803\\"}]",boolCol=t\n',
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as number", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as ns number", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000, "ns")
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as us number", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000, "us")
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as ms number", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000, "ms")
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as BigInt", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000n)
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as ns BigInt", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000000n, "ns")
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as us BigInt", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000n, "us")
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("supports timestamp field as ms BigInt", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000n, "ms")
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n",
+ );
+ await sender.close();
+ });
+
+ it("throws on invalid designated timestamp unit", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ try {
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ // @ts-expect-error - Testing invalid options
+ .at(1658484769000000, "foobar");
+ } catch (err) {
+ expect(err.message).toBe("Unknown timestamp unit: foobar");
+ }
+ await sender.close();
+ });
+
+ it("supports setting designated us timestamp as number from client", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .at(1658484769000000, "us");
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
+ );
+ await sender.close();
+ });
+
+ it("supports setting designated ms timestamp as number from client", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .at(1658484769000, "ms");
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
+ );
+ await sender.close();
+ });
+
+ it("supports setting designated timestamp as BigInt from client", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .at(1658484769000000n);
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
+ );
+ await sender.close();
+ });
+
+ it("supports setting designated ns timestamp as BigInt from client", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .at(1658484769000000123n, "ns");
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000123\n",
+ );
+ await sender.close();
+ });
+
+ it("supports setting designated us timestamp as BigInt from client", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .at(1658484769000000n, "us");
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
+ );
+ await sender.close();
+ });
+
+ it("supports setting designated ms timestamp as BigInt from client", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .at(1658484769000n, "ms");
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if table name is not a string", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ // @ts-expect-error - Invalid options
+ expect(() => sender.table(23456)).toThrow(
+ "Table name must be a string, received number",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if table name is too long", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender.table(
+ "123456789012345678901234567890123456789012345678901234567890" +
+ "12345678901234567890123456789012345678901234567890123456789012345678",
+ ),
+ ).toThrow("Table name is too long, max length is 127");
+ await sender.close();
+ });
+
+ it("throws exception if table name is set more times", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender.table("tableName").symbol("name", "value").table("newTableName"),
+ ).toThrow("Table name has already been set");
+ await sender.close();
+ });
+
+ it("throws exception if symbol name is not a string", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ // @ts-expect-error - Invalid options
+ expect(() => sender.table("tableName").symbol(12345.5656, "value")).toThrow(
+ "Symbol name must be a string, received number",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if symbol name is empty string", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() => sender.table("tableName").symbol("", "value")).toThrow(
+ "Empty string is not allowed as column name",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if column name is not a string", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ // @ts-expect-error - Invalid options
+ sender.table("tableName").stringColumn(12345.5656, "value"),
+ ).toThrow("Column name must be a string, received number");
+ await sender.close();
+ });
+
+ it("throws exception if column name is empty string", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() => sender.table("tableName").stringColumn("", "value")).toThrow(
+ "Empty string is not allowed as column name",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if column name is too long", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender
+ .table("tableName")
+ .stringColumn(
+ "123456789012345678901234567890123456789012345678901234567890" +
+ "12345678901234567890123456789012345678901234567890123456789012345678",
+ "value",
+ ),
+ ).toThrow("Column name is too long, max length is 127");
+ await sender.close();
+ });
+
+ it("throws exception if column value is not the right type", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ // @ts-expect-error - Invalid options
+ sender.table("tableName").stringColumn("columnName", false),
+ ).toThrow("Column value must be of type string, received boolean");
+ await sender.close();
+ });
+
+ it("throws exception if adding column without setting table name", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() => sender.floatColumn("name", 12.459)).toThrow(
+ "Column can be set only after table name is set",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if adding symbol without setting table name", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() => sender.symbol("name", "value")).toThrow(
+ "Symbol can be added only after table name is set and before any column added",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if adding symbol after columns", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender
+ .table("tableName")
+ .stringColumn("name", "value")
+ .symbol("symbolName", "symbolValue"),
+ ).toThrow("Symbol can be added only after table name is set and before any column added");
+ await sender.close();
+ });
+
+ it("returns null if preparing an empty buffer for send", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(sender.toBufferView()).toBe(null);
+ expect(sender.toBufferNew()).toBe(null);
+ await sender.close();
+ });
+
+ it("leaves unfinished rows in the sender's buffer when preparing a copy of the buffer for send", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ sender.table("tableName").symbol("name", "value");
+ await sender.at(1234567890n, "ns");
+ sender.table("tableName").symbol("name", "value2");
+
+ // copy of the sender's buffer contains the finished row
+ expect(sender.toBufferNew().toString()).toBe(
+ "tableName,name=value 1234567890\n",
+ );
+ // the sender's buffer is compacted, and contains only the unfinished row
+ // @ts-expect-error - Accessing private field
+ expect(sender.toBufferView(sender.position).toString()).toBe(
+ "tableName,name=value2",
+ );
+ await sender.close();
+ });
+
+ it("throws exception if a float is passed as integer field", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender.table("tableName").intColumn("intField", 123.222),
+ ).toThrow("Value must be an integer, received 123.222");
+ await sender.close();
+ });
+
+ it("throws exception if a float is passed as timestamp field", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender.table("tableName").timestampColumn("intField", 123.222),
+ ).toThrow("Value must be an integer or BigInt, received 123.222");
+ await sender.close();
+ });
+
+ it("throws exception if designated timestamp is not an integer or bigint", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ try {
+ await sender
+ .table("tableName")
+ .symbol("name", "value")
+ .at(23232322323.05);
+ } catch (e) {
+ expect(e.message).toEqual(
+ "Designated timestamp must be an integer or BigInt, received 23232322323.05",
+ );
+ }
+ await sender.close();
+ });
+
+ it("throws exception if designated timestamp is invalid", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ try {
+ // @ts-expect-error - Invalid options
+ await sender.table("tableName").symbol("name", "value").at("invalid_dts");
+ } catch (e) {
+ expect(e.message).toEqual(
+ "Designated timestamp must be an integer or BigInt, received invalid_dts",
+ );
+ }
+ await sender.close();
+ });
+
+ it("throws exception if designated timestamp is set without any fields added", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ try {
+ await sender.table("tableName").at(12345678n, "ns");
+ } catch (e) {
+ expect(e.message).toEqual(
+ "The row must have a symbol or column set before it is closed",
+ );
+ }
+ await sender.close();
+ });
+
+ it("extends the size of the buffer if data does not fit", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 8,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(8);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe(0);
+ sender.table("tableName");
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(16);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe("tableName".length);
+ sender.intColumn("intField", 123);
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(32);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe("tableName intField=123i".length);
+ await sender.atNow();
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(32);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe("tableName intField=123i\n".length);
+ expect(sender.toBufferView().toString()).toBe("tableName intField=123i\n");
+
+ await sender
+ .table("table2")
+ .intColumn("intField", 125)
+ .stringColumn("strField", "test")
+ .atNow();
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(64);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe(
+ 'tableName intField=123i\ntable2 intField=125i,strField="test"\n'.length,
+ );
+ expect(sender.toBufferView().toString()).toBe(
+ 'tableName intField=123i\ntable2 intField=125i,strField="test"\n',
+ );
+ await sender.close();
+ });
+
+ it("throws exception if tries to extend the size of the buffer above max buffer size", async function () {
+ const sender = Sender.fromConfig(
+ "tcp::addr=host;init_buf_size=8;max_buf_size=48;",
+ );
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(8);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe(0);
+ sender.table("tableName");
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(16);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe("tableName".length);
+ sender.intColumn("intField", 123);
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(32);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe("tableName intField=123i".length);
+ await sender.atNow();
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(32);
+ // @ts-expect-error - Accessing private field
+ expect(sender.position).toBe("tableName intField=123i\n".length);
+ expect(sender.toBufferView().toString()).toBe("tableName intField=123i\n");
+
+ try {
+ await sender
+ .table("table2")
+ .intColumn("intField", 125)
+ .stringColumn("strField", "test")
+ .atNow();
+ } catch (err) {
+ expect(err.message).toBe(
+ "Max buffer size is 48 bytes, requested buffer size: 64",
+ );
+ }
+ await sender.close();
+ });
+
+ it("is possible to clear the buffer by calling reset()", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ .timestampColumn("timestampCol", 1658484765000000)
+ .atNow();
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", false)
+ .timestampColumn("timestampCol", 1658484766000000)
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName boolCol=t,timestampCol=1658484765000000t\n" +
+ "tableName boolCol=f,timestampCol=1658484766000000t\n",
+ );
+
+ sender.reset();
+ await sender
+ .table("tableName")
+ .floatColumn("floatCol", 1234567890)
+ .timestampColumn("timestampCol", 1658484767000000)
+ .atNow();
+ expect(sender.toBufferView().toString()).toBe(
+ "tableName floatCol=1234567890,timestampCol=1658484767000000t\n",
+ );
+ await sender.close();
+ });
+});
diff --git a/test/sender.config.test.ts b/test/sender.config.test.ts
new file mode 100644
index 0000000..6676d65
--- /dev/null
+++ b/test/sender.config.test.ts
@@ -0,0 +1,402 @@
+// @ts-check
+import { describe, it, expect } from "vitest";
+
+import { Sender, DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE } from "../src/sender";
+import { log } from "../src/logging";
+
+describe("Sender configuration options suite", function () {
+ it("creates a sender from a configuration string", async function () {
+ await Sender.fromConfig("tcps::addr=hostname;").close();
+ });
+
+ it("creates a sender from a configuration string picked up from env", async function () {
+ process.env.QDB_CLIENT_CONF = "https::addr=hostname;";
+ await Sender.fromEnv().close();
+ });
+
+ it("throws exception if the username or the token is missing when TCP transport is used", async function () {
+ await expect(async () =>
+ await Sender.fromConfig("tcp::addr=hostname;username=bobo;").close()
+ ).rejects.toThrow(
+ "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
+ );
+
+ await expect(async () =>
+ await Sender.fromConfig("tcp::addr=hostname;token=bobo_token;").close()
+ ).rejects.toThrow(
+ "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
+ );
+ });
+
+ it("throws exception if tls_roots or tls_roots_password is used", async function () {
+ await expect(async () =>
+ await Sender.fromConfig("tcps::addr=hostname;username=bobo;tls_roots=bla;").close()
+ ).rejects.toThrow(
+ "'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
+ );
+
+ await expect(async () =>
+ await Sender.fromConfig("tcps::addr=hostname;token=bobo_token;tls_roots_password=bla;").close()
+ ).rejects.toThrow(
+ "'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
+ );
+ });
+
+ it("throws exception if connect() is called when http transport is used", async function () {
+ let sender: Sender;
+ await expect(async () => {
+ sender = Sender.fromConfig("http::addr=hostname");
+ await sender.connect();
+ }).rejects.toThrow("'connect()' is not required for HTTP transport");
+ await sender.close();
+ });
+});
+
+describe("Sender options test suite", function () {
+ it("fails if no options defined", async function () {
+ await expect(async () =>
+ // @ts-expect-error - Testing invalid options
+ await new Sender().close()
+ ).rejects.toThrow("The 'protocol' option is mandatory");
+ });
+
+ it("fails if options are null", async function () {
+ await expect(async () =>
+ await new Sender(null).close()
+ ).rejects.toThrow("The 'protocol' option is mandatory");
+ });
+
+ it("fails if options are undefined", async function () {
+ await expect(async () =>
+ await new Sender(undefined).close()
+ ).rejects.toThrow("The 'protocol' option is mandatory");
+ });
+
+ it("fails if options are empty", async function () {
+ await expect(async () =>
+ // @ts-expect-error - Testing invalid options
+ await new Sender({}).close()
+ ).rejects.toThrow("The 'protocol' option is mandatory");
+ });
+
+ it("fails if protocol option is missing", async function () {
+ await expect(async () =>
+ // @ts-expect-error - Testing invalid options
+ await new Sender({ host: "host" }).close()
+ ).rejects.toThrow("The 'protocol' option is mandatory");
+ });
+
+ it("fails if protocol option is invalid", async function () {
+ await expect(async () =>
+ await new Sender({ protocol: "abcd", host: "hostname" }).close()
+ ).rejects.toThrow("Invalid protocol: 'abcd'");
+ });
+
+ it("sets default buffer size if init_buf_size is not set", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets the requested buffer size if init_buf_size is set", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(1024);
+ await sender.close();
+ });
+
+ it("sets default buffer size if init_buf_size is set to null", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ init_buf_size: null,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets default buffer size if init_buf_size is set to undefined", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ init_buf_size: undefined,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets default buffer size if init_buf_size is not a number", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ // @ts-expect-error - Testing invalid options
+ init_buf_size: "1024",
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets the requested buffer size if 'bufferSize' is set, but warns that it is deprecated", async function () {
+ const log = (level: "error" | "warn" | "info" | "debug", message: string | Error) => {
+ expect(level).toBe("warn");
+ expect(message).toMatch("Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'");
+ };
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ // @ts-expect-error - Testing deprecated option
+ bufferSize: 2048,
+ log: log,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.bufferSize).toBe(2048);
+ await sender.close();
+ });
+
+ it("warns about deprecated option 'copy_buffer'", async function () {
+ const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
+ expect(level).toBe("warn");
+ expect(message).toMatch("Option 'copy_buffer' is not supported anymore, please, remove it");
+ };
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ // @ts-expect-error - Testing deprecated option
+ copy_buffer: false,
+ log: log,
+ });
+ await sender.close();
+ });
+
+ it("warns about deprecated option 'copyBuffer'", async function () {
+ const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
+ expect(level).toBe("warn");
+ expect(message).toMatch("Option 'copyBuffer' is not supported anymore, please, remove it");
+ };
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ // @ts-expect-error - Testing deprecated option
+ copyBuffer: false,
+ log: log,
+ });
+ await sender.close();
+ });
+
+ it("sets default max buffer size if max_buf_size is not set", async function () {
+ const sender = new Sender({ protocol: "http", host: "host" });
+ // @ts-expect-error - Accessing private field
+ expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets the requested max buffer size if max_buf_size is set", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ max_buf_size: 131072,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.maxBufferSize).toBe(131072);
+ await sender.close();
+ });
+
+ it("throws error if initial buffer size is greater than max_buf_size", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "http",
+ host: "host",
+ max_buf_size: 8192,
+ init_buf_size: 16384,
+ }).close()
+ ).rejects.toThrow("Max buffer size is 8192 bytes, requested buffer size: 16384")
+ });
+
+ it("sets default max buffer size if max_buf_size is set to null", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ max_buf_size: null,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets default max buffer size if max_buf_size is set to undefined", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ max_buf_size: undefined,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("sets default max buffer size if max_buf_size is not a number", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ // @ts-expect-error - Testing invalid value
+ max_buf_size: "1024",
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ await sender.close();
+ });
+
+ it("uses default logger if log function is not set", async function () {
+ const sender = new Sender({ protocol: "http", host: "host" });
+ // @ts-expect-error - Accessing private field
+ expect(sender.log).toBe(log);
+ await sender.close();
+ });
+
+ it("uses the required log function if it is set", async function () {
+ const testFunc = () => { };
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ log: testFunc,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.log).toBe(testFunc);
+ await sender.close();
+ });
+
+ it("uses default logger if log is set to null", async function () {
+ const sender = new Sender({ protocol: "http", host: "host", log: null });
+ // @ts-expect-error - Accessing private field
+ expect(sender.log).toBe(log);
+ await sender.close();
+ });
+
+ it("uses default logger if log is set to undefined", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ host: "host",
+ log: undefined,
+ });
+ // @ts-expect-error - Accessing private field
+ expect(sender.log).toBe(log);
+ await sender.close();
+ });
+
+ it("uses default logger if log is not a function", async function () {
+ // @ts-expect-error - Testing invalid options
+ const sender = new Sender({ protocol: "http", host: "host", log: "" });
+ // @ts-expect-error - Accessing private field
+ expect(sender.log).toBe(log);
+ await sender.close();
+ });
+});
+
+describe("Sender auth config checks suite", function () {
+ it("requires a username for authentication", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ token: "privateKey",
+ },
+ }).close()
+ ).rejects.toThrow(
+ "Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ });
+
+ it("requires a non-empty username", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "",
+ token: "privateKey",
+ },
+ }).close()
+ ).rejects.toThrow(
+ "Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ });
+
+ it("requires that the username is a string", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ // @ts-expect-error - Testing invalid options
+ keyId: 23,
+ token: "privateKey",
+ },
+ }).close()
+ ).rejects.toThrow(
+ "Please, specify the 'keyId' property of the 'auth' config option as a string. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ });
+
+ it("requires a private key for authentication", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "username",
+ },
+ }).close()
+ ).rejects.toThrow(
+ "Missing private key, please, specify the 'token' property of the 'auth' config option. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ });
+
+ it("requires a non-empty private key", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "username",
+ token: "",
+ },
+ }).close()
+ ).rejects.toThrow(
+ "Missing private key, please, specify the 'token' property of the 'auth' config option. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ });
+
+ it("requires that the private key is a string", async function () {
+ await expect(async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "username",
+ // @ts-expect-error - Testing invalid options
+ token: true,
+ },
+ }).close()
+ ).rejects.toThrow(
+ "Please, specify the 'token' property of the 'auth' config option as a string. " +
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ );
+ });
+});
diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts
new file mode 100644
index 0000000..cc9e410
--- /dev/null
+++ b/test/sender.integration.test.ts
@@ -0,0 +1,392 @@
+// @ts-check
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { GenericContainer } from "testcontainers";
+import http from "http";
+
+import { Sender } from "../src";
+
+const HTTP_OK = 200;
+
+const QUESTDB_HTTP_PORT = 9000;
+const QUESTDB_ILP_PORT = 9009;
+
+async function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe("Sender tests with containerized QuestDB instance", () => {
+ let container: any;
+
+ async function query(container: any, query: string) {
+ const options: http.RequestOptions = {
+ hostname: container.getHost(),
+ port: container.getMappedPort(QUESTDB_HTTP_PORT),
+ path: `/exec?query=${encodeURIComponent(query)}`,
+ method: "GET",
+ };
+
+ return new Promise((resolve, reject) => {
+ const req = http.request(options, (response) => {
+ if (response.statusCode === HTTP_OK) {
+ const body: Uint8Array[] = [];
+ response
+ .on("data", (data: Uint8Array) => {
+ body.push(data);
+ })
+ .on("end", () => {
+ resolve(JSON.parse(Buffer.concat(body).toString()));
+ });
+ } else {
+ reject(new Error(`HTTP request failed, statusCode=${response.statusCode}, query=${query}`));
+ }
+ });
+
+ req.on("error", error => reject(error));
+ req.end();
+ });
+ }
+
+ async function runSelect(container: any, select: string, expectedCount: number, timeout = 60000) {
+ const interval = 500;
+ const num = timeout / interval;
+ let selectResult: any;
+ for (let i = 0; i < num; i++) {
+ selectResult = await query(container, select);
+ if (selectResult && selectResult.count >= expectedCount) {
+ return selectResult;
+ }
+ await sleep(interval);
+ }
+ throw new Error(
+ `Timed out while waiting for ${expectedCount} rows, select='${select}'`,
+ );
+ }
+
+ async function waitForTable(container: any, tableName: string, timeout = 30000) {
+ await runSelect(container, `tables() where table_name='${tableName}'`, 1, timeout);
+ }
+
+ beforeAll(async () => {
+ container = await new GenericContainer("questdb/questdb:nightly")
+ .withExposedPorts(QUESTDB_HTTP_PORT, QUESTDB_ILP_PORT)
+ .start();
+
+ const stream = await container.logs();
+ stream
+ .on("data", (line: string) => console.log(line))
+ .on("err", (line: string) => console.error(line))
+ .on("end", () => console.log("Stream closed"));
+ }, 3000000);
+
+ afterAll(async () => {
+ await container.stop();
+ });
+
+ it("can ingest data via TCP and run queries", async () => {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: container.getHost(),
+ port: container.getMappedPort(QUESTDB_ILP_PORT),
+ });
+ await sender.connect();
+
+ const tableName = "test_tcp";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.1)
+ .at(1658484765000000000n, "ns");
+ await sender.flush();
+
+ // wait for the table
+ await waitForTable(container, tableName)
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 1);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(1);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ ["us", 17.1, "2022-07-22T10:12:45.000000Z"],
+ ]);
+
+ // ingest via client, add new column
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.3)
+ .at(1658484765000666000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .floatColumn("temperature", 17.4)
+ .at(1658484765000999000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .symbol("city", "london")
+ .floatColumn("temperature", 18.8)
+ .at(1658484765001234000n, "ns");
+ await sender.flush();
+
+ // query table
+ const select2Result = await runSelect(container, tableName, 4);
+ expect(select2Result.query).toBe(tableName);
+ expect(select2Result.count).toBe(4);
+ expect(select2Result.columns).toStrictEqual([
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ { name: "city", type: "SYMBOL" },
+ ]);
+ expect(select2Result.dataset).toStrictEqual([
+ ["us", 17.1, "2022-07-22T10:12:45.000000Z", null],
+ ["us", 17.3, "2022-07-22T10:12:45.000666Z", null],
+ ["emea", 17.4, "2022-07-22T10:12:45.000999Z", null],
+ ["emea", 18.8, "2022-07-22T10:12:45.001234Z", "london"],
+ ]);
+
+ await sender.close();
+ });
+
+ it("can ingest data via HTTP with auto flush rows", async () => {
+ const tableName = "test_http_rows";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_interval=0;auto_flush_rows=1`,
+ );
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.1)
+ .at(1658484765000000000n, "ns");
+
+ // wait for the table
+ await waitForTable(container, tableName)
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 1);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(1);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ ["us", 17.1, "2022-07-22T10:12:45.000000Z"],
+ ]);
+
+ // ingest via client, add new column
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.36)
+ .at(1658484765000666000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .floatColumn("temperature", 17.41)
+ .at(1658484765000999000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .symbol("city", "london")
+ .floatColumn("temperature", 18.81)
+ .at(1658484765001234000n, "ns");
+
+ // query table
+ const select2Result = await runSelect(container, tableName, 4);
+ expect(select2Result.query).toBe(tableName);
+ expect(select2Result.count).toBe(4);
+ expect(select2Result.columns).toStrictEqual([
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ { name: "city", type: "SYMBOL" },
+ ]);
+ expect(select2Result.dataset).toStrictEqual([
+ ["us", 17.1, "2022-07-22T10:12:45.000000Z", null],
+ ["us", 17.36, "2022-07-22T10:12:45.000666Z", null],
+ ["emea", 17.41, "2022-07-22T10:12:45.000999Z", null],
+ ["emea", 18.81, "2022-07-22T10:12:45.001234Z", "london"],
+ ]);
+
+ await sender.close();
+ });
+
+ it("can ingest data via HTTP with auto flush interval", async () => {
+ const tableName = "test_http_interval";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_interval=1;auto_flush_rows=0`,
+ );
+
+ // wait longer than the set auto flush interval to make sure there is a flush
+ await sleep(10);
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.1)
+ .at(1658484765000000000n, "ns");
+
+ // wait for the table
+ await waitForTable(container, tableName)
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 1);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(1);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ ["us", 17.1, "2022-07-22T10:12:45.000000Z"],
+ ]);
+
+ // ingest via client, add new column
+ await sleep(10);
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.36)
+ .at(1658484765000666000n, "ns");
+ await sleep(10);
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .floatColumn("temperature", 17.41)
+ .at(1658484765000999000n, "ns");
+ await sleep(10);
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .symbol("city", "london")
+ .floatColumn("temperature", 18.81)
+ .at(1658484765001234000n, "ns");
+
+ // query table
+ const select2Result = await runSelect(container, tableName, 4);
+ expect(select2Result.query).toBe(tableName);
+ expect(select2Result.count).toBe(4);
+ expect(select2Result.columns).toStrictEqual([
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ { name: "city", type: "SYMBOL" },
+ ]);
+ expect(select2Result.dataset).toStrictEqual([
+ ["us", 17.1, "2022-07-22T10:12:45.000000Z", null],
+ ["us", 17.36, "2022-07-22T10:12:45.000666Z", null],
+ ["emea", 17.41, "2022-07-22T10:12:45.000999Z", null],
+ ["emea", 18.81, "2022-07-22T10:12:45.001234Z", "london"],
+ ]);
+
+ await sender.close();
+ });
+
+ it("does not duplicate rows if await is missing when calling flush", async () => {
+ const sender = new Sender({
+ protocol: "tcp",
+ host: container.getHost(),
+ port: container.getMappedPort(QUESTDB_ILP_PORT),
+ });
+ await sender.connect();
+
+ const tableName = "test2";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperature", type: "DOUBLE" },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ // ingest via client
+ const numOfRows = 100;
+ for (let i = 0; i < numOfRows; i++) {
+ const p1 = sender
+ .table(tableName)
+ .symbol("location", "us")
+ .floatColumn("temperature", i)
+ .at(1658484765000000000n, "ns");
+ const p2 = sender.flush();
+ // IMPORTANT: missing 'await' for p1 and p2 is intentional!
+ expect(p1).toBeTruthy();
+ expect(p2).toBeTruthy();
+ }
+
+ // wait for the table
+ await waitForTable(container, tableName)
+
+ // query table
+ const selectQuery = `${tableName} order by temperature`;
+ const selectResult = await runSelect(container, selectQuery, numOfRows);
+ expect(selectResult.query).toBe(selectQuery);
+ expect(selectResult.count).toBe(numOfRows);
+ expect(selectResult.columns).toStrictEqual(schema);
+
+ const expectedData: (string | number)[][] = [];
+ for (let i = 0; i < numOfRows; i++) {
+ expectedData.push(["us", i, "2022-07-22T10:12:45.000000Z"]);
+ }
+ expect(selectResult.dataset).toStrictEqual(expectedData);
+
+ await sender.close();
+ });
+
+ it("ingests all data without loss under high load with auto-flush", async () => {
+ const sender = Sender.fromConfig(
+ `tcp::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_ILP_PORT)};auto_flush_rows=5;auto_flush_interval=1`,
+ );
+ await sender.connect();
+
+ const tableName = "test_high_load_autoflush";
+ const numOfRows = 1000;
+ const promises: Promise[] = [];
+
+ for (let i = 0; i < numOfRows; i++) {
+ // Not awaiting each .at() call individually to allow them to queue up
+ const p = sender
+ .table(tableName)
+ .intColumn("id", i)
+ .at(1658484765000000000n + BigInt(1000 * i), "ns"); // Unique timestamp for each row
+ promises.push(p);
+ }
+
+ // Wait for all .at() calls to complete their processing (including triggering auto-flushes)
+ await Promise.all(promises);
+
+ // Perform a final flush to ensure any data remaining in the buffer is sent.
+ // This will be queued correctly after any ongoing auto-flushes.
+ await sender.flush();
+
+ // Wait for the table
+ await waitForTable(container, tableName)
+
+ // Query table and verify count
+ const selectQuery = `SELECT id FROM ${tableName}`;
+ const selectResult = await runSelect(container, selectQuery, numOfRows);
+ expect(selectResult.count).toBe(numOfRows);
+
+ // Verify data integrity
+ for (let i = 0; i < numOfRows; i++) {
+ expect(selectResult.dataset[i][0]).toBe(i);
+ }
+
+ await sender.close();
+ }, 30000); // Increased test timeout for this specific test
+});
diff --git a/test/sender.test.ts b/test/sender.test.ts
deleted file mode 100644
index 57a60b5..0000000
--- a/test/sender.test.ts
+++ /dev/null
@@ -1,2176 +0,0 @@
-import { Sender } from "../src";
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
-import { DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE } from "../src/sender";
-import { readFileSync } from "fs";
-import { MockProxy } from "./_utils_/mockproxy";
-import { MockHttp } from "./_utils_/mockhttp";
-import { GenericContainer } from "testcontainers";
-import http from "http";
-import { Agent } from "undici";
-import { SenderOptions } from "../src/options";
-import { fail } from "node:assert";
-import { log } from "../src/logging";
-
-const HTTP_OK = 200;
-
-const QUESTDB_HTTP_PORT = 9000;
-const QUESTDB_ILP_PORT = 9009;
-const MOCK_HTTP_PORT = 9099;
-const MOCK_HTTPS_PORT = 9098;
-const PROXY_PORT = 9088;
-const PROXY_HOST = "localhost";
-
-const proxyOptions = {
- key: readFileSync("test/certs/server/server.key"),
- cert: readFileSync("test/certs/server/server.crt"),
- ca: readFileSync("test/certs/ca/ca.crt"),
-};
-
-const USER_NAME = "testapp";
-const PRIVATE_KEY = "9b9x5WhJywDEuo1KGQWSPNxtX-6X6R2BRCKhYMMY6n8";
-const AUTH: SenderOptions["auth"] = {
- keyId: USER_NAME,
- token: PRIVATE_KEY,
-};
-
-async function sleep(ms: number) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-describe("Sender configuration options suite", function () {
- it("creates a sender from a configuration string", async function () {
- await Sender.fromConfig("tcps::addr=hostname;").close();
- });
-
- it("creates a sender from a configuration string picked up from env", async function () {
- process.env.QDB_CLIENT_CONF = "https::addr=hostname;";
- await Sender.fromEnv().close();
- });
-
- it("throws exception if the username or the token is missing when TCP transport is used", async function () {
- try {
- await Sender.fromConfig("tcp::addr=hostname;username=bobo;").close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe(
- "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
- );
- }
-
- try {
- await Sender.fromConfig("tcp::addr=hostname;token=bobo_token;").close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe(
- "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
- );
- }
- });
-
- it("throws exception if tls_roots or tls_roots_password is used", async function () {
- try {
- await Sender.fromConfig(
- "tcps::addr=hostname;username=bobo;tls_roots=bla;",
- ).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe(
- "'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
- );
- }
-
- try {
- await Sender.fromConfig(
- "tcps::addr=hostname;token=bobo_token;tls_roots_password=bla;",
- ).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe(
- "'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
- );
- }
- });
-
- it("throws exception if connect() is called when http transport is used", async function () {
- let sender: Sender;
- try {
- sender = Sender.fromConfig("http::addr=hostname");
- await sender.connect();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe(
- "'connect()' should be called only if the sender connects via TCP",
- );
- }
- await sender.close();
- });
-});
-
-describe("Sender options test suite", function () {
- it("fails if no options defined", async function () {
- try {
- // @ts-expect-error - Testing invalid options
- await new Sender().close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("The 'protocol' option is mandatory");
- }
- });
-
- it("fails if options are null", async function () {
- try {
- await new Sender(null).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("The 'protocol' option is mandatory");
- }
- });
-
- it("fails if options are undefined", async function () {
- try {
- await new Sender(undefined).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("The 'protocol' option is mandatory");
- }
- });
-
- it("fails if options are empty", async function () {
- try {
- // @ts-expect-error - Testing invalid options
- await new Sender({}).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("The 'protocol' option is mandatory");
- }
- });
-
- it("fails if protocol option is missing", async function () {
- try {
- // @ts-expect-error - Testing invalid options
- await new Sender({ host: "host" }).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("The 'protocol' option is mandatory");
- }
- });
-
- it("fails if protocol option is invalid", async function () {
- try {
- await new Sender({ protocol: "abcd" }).close();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("Invalid protocol: 'abcd'");
- }
- });
-
- it("sets default buffer size if init_buf_size is not set", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- });
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets the requested buffer size if init_buf_size is set", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- init_buf_size: 1024,
- });
- expect(sender.bufferSize).toBe(1024);
- await sender.close();
- });
-
- it("sets default buffer size if init_buf_size is set to null", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- init_buf_size: null,
- });
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets default buffer size if init_buf_size is set to undefined", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- init_buf_size: undefined,
- });
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets default buffer size if init_buf_size is not a number", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- // @ts-expect-error - Testing invalid options
- init_buf_size: "1024",
- });
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets the requested buffer size if 'bufferSize' is set, but warns that it is deprecated", async function () {
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("warn");
- expect(message).toMatch("Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'");
- };
- const sender = new Sender({
- protocol: "http",
- host: "host",
- // @ts-expect-error - Testing deprecated option
- bufferSize: 2048,
- log: log,
- });
- expect(sender.bufferSize).toBe(2048);
- await sender.close();
- });
-
- it("warns about deprecated option 'copy_buffer'", async function () {
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("warn");
- expect(message).toMatch("Option 'copy_buffer' is not supported anymore, please, remove it");
- };
- const sender = new Sender({
- protocol: "http",
- host: "host",
- // @ts-expect-error - Testing deprecated option
- copy_buffer: false,
- log: log,
- });
- await sender.close();
- });
-
- it("warns about deprecated option 'copyBuffer'", async function () {
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("warn");
- expect(message).toMatch("Option 'copyBuffer' is not supported anymore, please, remove it");
- };
- const sender = new Sender({
- protocol: "http",
- host: "host",
- // @ts-expect-error - Testing deprecated option
- copyBuffer: false,
- log: log,
- });
- await sender.close();
- });
-
- it("sets default max buffer size if max_buf_size is not set", async function () {
- const sender = new Sender({ protocol: "http", host: "host" });
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets the requested max buffer size if max_buf_size is set", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- max_buf_size: 131072,
- });
- expect(sender.maxBufferSize).toBe(131072);
- await sender.close();
- });
-
- it("throws error if initial buffer size is greater than max_buf_size", async function () {
- try {
- await new Sender({
- protocol: "http",
- host: "host",
- max_buf_size: 8192,
- init_buf_size: 16384,
- }).close();
- fail('Expected error is not thrown');
- } catch (err) {
- expect(err.message).toBe(
- "Max buffer size is 8192 bytes, requested buffer size: 16384",
- );
- }
- });
-
- it("sets default max buffer size if max_buf_size is set to null", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- max_buf_size: null,
- });
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets default max buffer size if max_buf_size is set to undefined", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- max_buf_size: undefined,
- });
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
- await sender.close();
- });
-
- it("sets default max buffer size if max_buf_size is not a number", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- // @ts-expect-error - Testing invalid vlaue
- max_buf_size: "1024",
- });
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
- await sender.close();
- });
-
- it("uses default logger if log function is not set", async function () {
- const sender = new Sender({ protocol: "http", host: "host" });
- expect(sender.log).toBe(log);
- await sender.close();
- });
-
- it("uses the required log function if it is set", async function () {
- const testFunc = () => { };
- const sender = new Sender({
- protocol: "http",
- host: "host",
- log: testFunc,
- });
- expect(sender.log).toBe(testFunc);
- await sender.close();
- });
-
- it("uses default logger if log is set to null", async function () {
- const sender = new Sender({ protocol: "http", host: "host", log: null });
- expect(sender.log).toBe(log);
- await sender.close();
- });
-
- it("uses default logger if log is set to undefined", async function () {
- const sender = new Sender({
- protocol: "http",
- host: "host",
- log: undefined,
- });
- expect(sender.log).toBe(log);
- await sender.close();
- });
-
- it("uses default logger if log is not a function", async function () {
- // @ts-expect-error - Testing invalid options
- const sender = new Sender({ protocol: "http", host: "host", log: "" });
- expect(sender.log).toBe(log);
- await sender.close();
- });
-});
-
-describe("Sender auth config checks suite", function () {
- it("requires a username for authentication", async function () {
- try {
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- token: "privateKey",
- },
- }).close();
- fail("it should not be able to create the sender");
- } catch (err) {
- expect(err.message).toBe(
- "Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
- }
- });
-
- it("requires a non-empty username", async function () {
- try {
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "",
- token: "privateKey",
- },
- }).close();
- fail("it should not be able to create the sender");
- } catch (err) {
- expect(err.message).toBe(
- "Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
- }
- });
-
- it("requires that the username is a string", async function () {
- try {
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- // @ts-expect-error - Testing invalid options
- keyId: 23,
- token: "privateKey",
- },
- }).close();
- fail("it should not be able to create the sender");
- } catch (err) {
- expect(err.message).toBe(
- "Please, specify the 'keyId' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
- }
- });
-
- it("requires a private key for authentication", async function () {
- try {
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "username",
- },
- }).close();
- fail("it should not be able to create the sender");
- } catch (err) {
- expect(err.message).toBe(
- "Missing private key, please, specify the 'token' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
- }
- });
-
- it("requires a non-empty private key", async function () {
- try {
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "username",
- token: "",
- },
- }).close();
- fail("it should not be able to create the sender");
- } catch (err) {
- expect(err.message).toBe(
- "Missing private key, please, specify the 'token' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
- }
- });
-
- it("requires that the private key is a string", async function () {
- try {
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "username",
- // @ts-expect-error - Testing invalid options
- token: true,
- },
- }).close();
- fail("it should not be able to create the sender");
- } catch (err) {
- expect(err.message).toBe(
- "Please, specify the 'token' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
- );
- }
- });
-});
-
-describe("Sender HTTP suite", function () {
- async function sendData(sender: Sender) {
- await sender
- .table("test")
- .symbol("location", "us")
- .floatColumn("temperature", 17.1)
- .at(1658484765000000000n, "ns");
- await sender.flush();
- }
-
- const mockHttp = new MockHttp();
- const mockHttps = new MockHttp();
-
- beforeAll(async function () {
- await mockHttp.start(MOCK_HTTP_PORT);
- await mockHttps.start(MOCK_HTTPS_PORT, true, proxyOptions);
- });
-
- afterAll(async function () {
- await mockHttp.stop();
- await mockHttps.stop();
- });
-
- it("can ingest via HTTP", async function () {
- mockHttp.reset();
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- );
- await sendData(sender);
- expect(mockHttp.numOfRequests).toBe(1);
-
- await sender.close();
- });
-
- it("supports custom http agent", async function () {
- mockHttp.reset();
- const agent = new Agent({ pipelining: 3 });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- { agent: agent },
- );
- await sendData(sender);
- expect(mockHttp.numOfRequests).toBe(1);
-
- const symbols = Object.getOwnPropertySymbols(sender.agent);
- expect(sender.agent[symbols[6]]).toEqual({ pipelining: 3 });
-
- await sender.close();
- await agent.destroy();
- });
-
- it("can ingest via HTTPS", async function () {
- mockHttps.reset();
-
- const senderCertCheckFail = Sender.fromConfig(
- `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT}`,
- );
- await expect(sendData(senderCertCheckFail)).rejects.toThrowError(
- "HTTP request failed, statusCode=unknown, error=self-signed certificate in certificate chain",
- );
- await senderCertCheckFail.close();
-
- const senderWithCA = Sender.fromConfig(
- `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};tls_ca=test/certs/ca/ca.crt`,
- );
- await sendData(senderWithCA);
- expect(mockHttps.numOfRequests).toEqual(1);
- await senderWithCA.close();
-
- const senderVerifyOff = Sender.fromConfig(
- `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
- );
- await sendData(senderVerifyOff);
- expect(mockHttps.numOfRequests).toEqual(2);
- await senderVerifyOff.close();
- }, 20000);
-
- it("can ingest via HTTP with basic auth", async function () {
- mockHttp.reset({ username: "user1", password: "pwd" });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1;password=pwd`,
- );
- await sendData(sender);
- expect(mockHttp.numOfRequests).toEqual(1);
- await sender.close();
-
- const senderFailPwd = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1;password=xyz`,
- );
- await expect(sendData(senderFailPwd)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailPwd.close();
-
- const senderFailMissingPwd = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1z`,
- );
- await expect(sendData(senderFailMissingPwd)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailMissingPwd.close();
-
- const senderFailUsername = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=xyz;password=pwd`,
- );
- await expect(sendData(senderFailUsername)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailUsername.close();
-
- const senderFailMissingUsername = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};password=pwd`,
- );
- await expect(sendData(senderFailMissingUsername)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailMissingUsername.close();
-
- const senderFailMissing = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- );
- await expect(sendData(senderFailMissing)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailMissing.close();
- });
-
- it("can ingest via HTTP with token auth", async function () {
- mockHttp.reset({ token: "abcdefghijkl123" });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};token=abcdefghijkl123`,
- );
- await sendData(sender);
- expect(mockHttp.numOfRequests).toBe(1);
- await sender.close();
-
- const senderFailToken = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};token=xyz`,
- );
- await expect(sendData(senderFailToken)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailToken.close();
-
- const senderFailMissing = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- );
- await expect(sendData(senderFailMissing)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
- await senderFailMissing.close();
- });
-
- it("can retry via HTTP", async function () {
- mockHttp.reset({ responseCodes: [204, 500, 523, 504, 500] });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- );
- await sendData(sender);
- expect(mockHttp.numOfRequests).toBe(5);
-
- await sender.close();
- });
-
- it("fails when retry timeout expires", async function () {
- // TODO: artificial delay (responseDelays) is the same as retry timeout,
- // This should result in the request failing on the second try.
- // However, with undici transport sometimes we reach the third request too.
- // Investigate why, probably because of pipelining?
- mockHttp.reset({
- responseCodes: [204, 500, 500],
- responseDelays: [1000, 1000, 1000],
- });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=1000`,
- );
- await expect(sendData(sender)).rejects.toThrowError(
- "HTTP request failed, statusCode=500, error=Request failed"
- );
- await sender.close();
- });
-
- it("fails when HTTP request times out", async function () {
- // artificial delay (responseDelays) is greater than request timeout, and retry is switched off
- // should result in the request failing with timeout
- mockHttp.reset({
- responseCodes: [204],
- responseDelays: [1000],
- });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=0;request_timeout=100`,
- );
- await expect(sendData(sender)).rejects.toThrowError(
- "HTTP request timeout, statusCode=undefined, error=Headers Timeout Error",
- );
- await sender.close();
- });
-
- it("succeeds on the third request after two timeouts", async function () {
- mockHttp.reset({
- responseCodes: [204, 504, 504],
- responseDelays: [2000, 2000],
- });
-
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=30000;request_timeout=1000`,
- );
- await sendData(sender);
-
- await sender.close();
- });
-
- it("accepts custom http agent", async function () {
- mockHttp.reset();
- const agent = new Agent({ connect: { keepAlive: false } });
-
- const num = 300;
- const senders: Sender[] = [];
- const promises: Promise[] = [];
- for (let i = 0; i < num; i++) {
- const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- { agent: agent },
- );
- senders.push(sender);
- const promise = sendData(sender);
- promises.push(promise);
- }
- await Promise.all(promises);
- expect(mockHttp.numOfRequests).toBe(num);
-
- for (const sender of senders) {
- await sender.close();
- }
- await agent.destroy();
- });
-});
-
-describe("Sender connection suite", function () {
- async function createProxy(
- auth = false,
- tlsOptions?: Record,
- ) {
- const mockConfig = { auth: auth, assertions: true };
- const proxy = new MockProxy(mockConfig);
- await proxy.start(PROXY_PORT, tlsOptions);
- expect(proxy.mockConfig).toBe(mockConfig);
- expect(proxy.dataSentToRemote).toStrictEqual([]);
- return proxy;
- }
-
- async function createSender(auth: SenderOptions["auth"], secure = false) {
- const sender = new Sender({
- protocol: secure ? "tcps" : "tcp",
- port: PROXY_PORT,
- host: PROXY_HOST,
- auth: auth,
- tls_ca: "test/certs/ca/ca.crt",
- });
- const connected = await sender.connect();
- expect(connected).toBe(true);
- return sender;
- }
-
- async function sendData(sender: Sender) {
- await sender
- .table("test")
- .symbol("location", "us")
- .floatColumn("temperature", 17.1)
- .at(1658484765000000000n, "ns");
- await sender.flush();
- }
-
- async function assertSentData(
- proxy: MockProxy,
- authenticated: boolean,
- expected: string,
- timeout = 60000,
- ) {
- const interval = 100;
- const num = timeout / interval;
- let actual: string;
- for (let i = 0; i < num; i++) {
- const dataSentToRemote = proxy.getDataSentToRemote().join("").split("\n");
- if (authenticated) {
- dataSentToRemote.splice(1, 1);
- }
- actual = dataSentToRemote.join("\n");
- if (actual === expected) {
- return new Promise((resolve) => resolve(null));
- }
- await sleep(interval);
- }
- return new Promise((resolve) =>
- resolve(`data assert failed [expected=${expected}, actual=${actual}]`),
- );
- }
-
- it("can authenticate", async function () {
- const proxy = await createProxy(true);
- const sender = await createSender(AUTH);
- await assertSentData(proxy, true, "testapp\n");
- await sender.close();
- await proxy.stop();
- });
-
- it("can authenticate with a different private key", async function () {
- const proxy = await createProxy(true);
- const sender = await createSender({
- keyId: "user1",
- token: "zhPiK3BkYMYJvRf5sqyrWNJwjDKHOWHnRbmQggUll6A",
- });
- await assertSentData(proxy, true, "user1\n");
- await sender.close();
- await proxy.stop();
- });
-
- it("is backwards compatible and still can authenticate with full JWK", async function () {
- const JWK = {
- x: "BtUXC_K3oAyGlsuPjTgkiwirMUJhuRQDfcUHeyoxFxU",
- y: "R8SOup-rrNofB7wJagy4HrJhTVfrVKmj061lNRk3bF8",
- kid: "user2",
- kty: "EC",
- d: "hsg6Zm4kSBlIEvKUWT3kif-2y2Wxw-iWaGrJxrPXQhs",
- crv: "P-256",
- };
-
- const proxy = await createProxy(true);
- const sender = new Sender({
- protocol: "tcp",
- port: PROXY_PORT,
- host: PROXY_HOST,
- jwk: JWK,
- });
- const connected = await sender.connect();
- expect(connected).toBe(true);
- await assertSentData(proxy, true, "user2\n");
- await sender.close();
- await proxy.stop();
- });
-
- it("can connect unauthenticated", async function () {
- const proxy = await createProxy();
- // @ts-expect-error invalid options
- const sender = await createSender();
- await assertSentData(proxy, false, "");
- await sender.close();
- await proxy.stop();
- });
-
- it("can authenticate and send data to server", async function () {
- const proxy = await createProxy(true);
- const sender = await createSender(AUTH);
- await sendData(sender);
- await assertSentData(
- proxy,
- true,
- "testapp\ntest,location=us temperature=17.1 1658484765000000000\n",
- );
- await sender.close();
- await proxy.stop();
- });
-
- it("can connect unauthenticated and send data to server", async function () {
- const proxy = await createProxy();
- // @ts-expect-error invalid options
- const sender = await createSender();
- await sendData(sender);
- await assertSentData(
- proxy,
- false,
- "test,location=us temperature=17.1 1658484765000000000\n",
- );
- await sender.close();
- await proxy.stop();
- });
-
- it("can authenticate and send data to server via secure connection", async function () {
- const proxy = await createProxy(true, proxyOptions);
- const sender = await createSender(AUTH, true);
- await sendData(sender);
- await assertSentData(
- proxy,
- true,
- "testapp\ntest,location=us temperature=17.1 1658484765000000000\n",
- );
- await sender.close();
- await proxy.stop();
- });
-
- it("can connect unauthenticated and send data to server via secure connection", async function () {
- const proxy = await createProxy(false, proxyOptions);
- const sender = await createSender(null, true);
- await sendData(sender);
- await assertSentData(
- proxy,
- false,
- "test,location=us temperature=17.1 1658484765000000000\n",
- );
- await sender.close();
- await proxy.stop();
- });
-
- it("fails to connect without hostname and port", async function () {
- const sender = new Sender({ protocol: "tcp" });
- try {
- await sender.connect();
- fail("it should not be able to connect");
- } catch (err) {
- expect(err.message).toBe("Hostname is not set");
- }
- await sender.close();
- });
-
- it("fails to send data if not connected", async function () {
- const sender = new Sender({ protocol: "tcp", host: "localhost" });
- try {
- await sender.table("test").symbol("location", "us").atNow();
- await sender.flush();
- fail("it should not be able to send data");
- } catch (err) {
- expect(err.message).toBe("TCP send failed, error=Sender is not connected");
- }
- await sender.close();
- });
-
- it("guards against multiple connect calls", async function () {
- const proxy = await createProxy(true, proxyOptions);
- const sender = await createSender(AUTH, true);
- try {
- await sender.connect();
- fail("it should not be able to connect again");
- } catch (err) {
- expect(err.message).toBe("Sender connected already");
- }
- await sender.close();
- await proxy.stop();
- });
-
- it("guards against concurrent connect calls", async function () {
- const proxy = await createProxy(true, proxyOptions);
- const sender = new Sender({
- protocol: "tcps",
- port: PROXY_PORT,
- host: PROXY_HOST,
- auth: AUTH,
- tls_ca: "test/certs/ca/ca.crt",
- });
- try {
- await Promise.all([sender.connect(), sender.connect()]);
- fail("it should not be able to connect twice");
- } catch (err) {
- expect(err.message).toBe("Sender connected already");
- }
- await sender.close();
- await proxy.stop();
- });
-
- it("can disable the server certificate check", async function () {
- const proxy = await createProxy(true, proxyOptions);
- const senderCertCheckFail = Sender.fromConfig(
- `tcps::addr=${PROXY_HOST}:${PROXY_PORT}`,
- );
- try {
- await senderCertCheckFail.connect();
- fail("it should not be able to connect");
- } catch (err) {
- expect(err.message).toMatch(
- /^self[ -]signed certificate in certificate chain$/,
- );
- }
- await senderCertCheckFail.close();
-
- const senderCertCheckOn = Sender.fromConfig(
- `tcps::addr=${PROXY_HOST}:${PROXY_PORT};tls_ca=test/certs/ca/ca.crt`,
- );
- await senderCertCheckOn.connect();
- await senderCertCheckOn.close();
-
- const senderCertCheckOff = Sender.fromConfig(
- `tcps::addr=${PROXY_HOST}:${PROXY_PORT};tls_verify=unsafe_off`,
- );
- await senderCertCheckOff.connect();
- await senderCertCheckOff.close();
- await proxy.stop();
- });
-
- it("can handle unfinished rows during flush()", async function () {
- const proxy = await createProxy(true, proxyOptions);
- const sender = await createSender(AUTH, true);
- sender.table("test").symbol("location", "us");
- const sent = await sender.flush();
- expect(sent).toBe(false);
- await assertSentData(proxy, true, "testapp\n");
- await sender.close();
- await proxy.stop();
- });
-
- it("supports custom logger", async function () {
- const expectedMessages = [
- "Successfully connected to localhost:9088",
- /^Connection to .*1:9088 is closed$/,
- ];
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("info");
- expect(message).toMatch(expectedMessages.shift());
- };
- const proxy = await createProxy();
- const sender = new Sender({
- protocol: "tcp",
- port: PROXY_PORT,
- host: PROXY_HOST,
- log: log,
- });
- await sender.connect();
- await sendData(sender);
- await assertSentData(
- proxy,
- false,
- "test,location=us temperature=17.1 1658484765000000000\n",
- );
- await sender.close();
- await proxy.stop();
- });
-});
-
-describe("Client interop test suite", function () {
- it("runs client tests as per json test config", async function () {
- const testCases = JSON.parse(
- readFileSync(
- "./questdb-client-test/ilp-client-interop-test.json",
- ).toString(),
- );
-
- loopTestCase: for (const testCase of testCases) {
- console.info(`test name: ${testCase.testName}`);
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- try {
- sender.table(testCase.table);
- for (const symbol of testCase.symbols) {
- sender.symbol(symbol.name, symbol.value);
- }
- for (const column of testCase.columns) {
- switch (column.type) {
- case "STRING":
- sender.stringColumn(column.name, column.value);
- break;
- case "LONG":
- sender.intColumn(column.name, column.value);
- break;
- case "DOUBLE":
- sender.floatColumn(column.name, column.value);
- break;
- case "BOOLEAN":
- sender.booleanColumn(column.name, column.value);
- break;
- case "TIMESTAMP":
- sender.timestampColumn(column.name, column.value);
- break;
- default:
- fail("Unsupported column type");
- }
- }
- await sender.atNow();
- } catch (e) {
- if (testCase.result.status !== "ERROR") {
- fail("Did not expect error: " + e.message);
- }
- await sender.close();
- continue;
- }
-
- const buffer = sender.toBufferView();
- if (testCase.result.status === "SUCCESS") {
- if (testCase.result.line) {
- expect(buffer.toString()).toBe(testCase.result.line + "\n");
- } else {
- for (const line of testCase.result.anyLines) {
- if (buffer.toString() === line + "\n") {
- // test passed
- await sender.close();
- continue loopTestCase;
- }
- }
- fail("Line is not matching any of the expected results: " + buffer.toString());
- }
- } else {
- fail("Expected error missing, instead we have a line: " + buffer.toString());
- }
-
- await sender.close();
- }
- });
-});
-
-describe("Sender message builder test suite (anything not covered in client interop test suite)", function () {
- it("throws on invalid timestamp unit", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- try {
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- // @ts-expect-error - Testing invalid options
- .timestampColumn("timestampCol", 1658484765000000, "foobar")
- .atNow();
- fail("Expected error is not thrown");
- } catch (err) {
- expect(err.message).toBe("Unknown timestamp unit: foobar");
- }
- await sender.close();
- });
-
- it("supports json object", async function () {
- const pages: Array<{
- id: string;
- gridId: string;
- }>[] = [];
- for (let i = 0; i < 4; i++) {
- const pageProducts: Array<{
- id: string;
- gridId: string;
- }> = [
- {
- id: "46022e96-076f-457f-b630-51b82b871618" + i,
- gridId: "46022e96-076f-457f-b630-51b82b871618",
- },
- {
- id: "55615358-4af1-4179-9153-faaa57d71e55",
- gridId: "55615358-4af1-4179-9153-faaa57d71e55",
- },
- {
- id: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
- gridId: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
- },
- {
- id: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280",
- gridId: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280" + i,
- },
- ];
- pages.push(pageProducts);
- }
-
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 256,
- });
- for (const p of pages) {
- await sender
- .table("tableName")
- .stringColumn("page_products", JSON.stringify(p || []))
- .booleanColumn("boolCol", true)
- .atNow();
- }
- expect(sender.toBufferView().toString()).toBe(
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716180\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2800\\"}]",boolCol=t\n' +
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716181\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2801\\"}]",boolCol=t\n' +
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716182\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2802\\"}]",boolCol=t\n' +
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716183\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2803\\"}]",boolCol=t\n',
- );
- await sender.close();
- });
-
- it("supports timestamp field as number", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as ns number", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000, "ns")
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as us number", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000, "us")
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as ms number", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000, "ms")
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as BigInt", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000n)
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as ns BigInt", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000000n, "ns")
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as us BigInt", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000n, "us")
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("supports timestamp field as ms BigInt", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000n, "ms")
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n",
- );
- await sender.close();
- });
-
- it("throws on invalid designated timestamp unit", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- try {
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- // @ts-expect-error - Testing invalid options
- .at(1658484769000000, "foobar");
- } catch (err) {
- expect(err.message).toBe("Unknown timestamp unit: foobar");
- }
- await sender.close();
- });
-
- it("supports setting designated us timestamp as number from client", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .at(1658484769000000, "us");
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
- );
- await sender.close();
- });
-
- it("supports setting designated ms timestamp as number from client", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .at(1658484769000, "ms");
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
- );
- await sender.close();
- });
-
- it("supports setting designated timestamp as BigInt from client", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .at(1658484769000000n);
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
- );
- await sender.close();
- });
-
- it("supports setting designated ns timestamp as BigInt from client", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .at(1658484769000000123n, "ns");
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000123\n",
- );
- await sender.close();
- });
-
- it("supports setting designated us timestamp as BigInt from client", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .at(1658484769000000n, "us");
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
- );
- await sender.close();
- });
-
- it("supports setting designated ms timestamp as BigInt from client", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .at(1658484769000n, "ms");
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
- );
- await sender.close();
- });
-
- it("throws exception if table name is not a string", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- // @ts-expect-error invalid options
- expect(() => sender.table(23456)).toThrow(
- "Table name must be a string, received number",
- );
- await sender.close();
- });
-
- it("throws exception if table name is too long", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- sender.table(
- "123456789012345678901234567890123456789012345678901234567890" +
- "12345678901234567890123456789012345678901234567890123456789012345678",
- ),
- ).toThrow("Table name is too long, max length is 127");
- await sender.close();
- });
-
- it("throws exception if table name is set more times", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- sender.table("tableName").symbol("name", "value").table("newTableName"),
- ).toThrow("Table name has already been set");
- await sender.close();
- });
-
- it("throws exception if symbol name is not a string", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- // @ts-expect-error invalid options
- expect(() => sender.table("tableName").symbol(12345.5656, "value")).toThrow(
- "Symbol name must be a string, received number",
- );
- await sender.close();
- });
-
- it("throws exception if symbol name is empty string", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() => sender.table("tableName").symbol("", "value")).toThrow(
- "Empty string is not allowed as column name",
- );
- await sender.close();
- });
-
- it("throws exception if column name is not a string", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- // @ts-expect-error invalid options
- sender.table("tableName").stringColumn(12345.5656, "value"),
- ).toThrow("Column name must be a string, received number");
- await sender.close();
- });
-
- it("throws exception if column name is empty string", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() => sender.table("tableName").stringColumn("", "value")).toThrow(
- "Empty string is not allowed as column name",
- );
- await sender.close();
- });
-
- it("throws exception if column name is too long", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- sender
- .table("tableName")
- .stringColumn(
- "123456789012345678901234567890123456789012345678901234567890" +
- "12345678901234567890123456789012345678901234567890123456789012345678",
- "value",
- ),
- ).toThrow("Column name is too long, max length is 127");
- await sender.close();
- });
-
- it("throws exception if column value is not the right type", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- // @ts-expect-error invalid options
- sender.table("tableName").stringColumn("columnName", false),
- ).toThrow("Column value must be of type string, received boolean");
- await sender.close();
- });
-
- it("throws exception if adding column without setting table name", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() => sender.floatColumn("name", 12.459)).toThrow(
- "Column can be set only after table name is set",
- );
- await sender.close();
- });
-
- it("throws exception if adding symbol without setting table name", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() => sender.symbol("name", "value")).toThrow(
- "Symbol can be added only after table name is set and before any column added",
- );
- await sender.close();
- });
-
- it("throws exception if adding symbol after columns", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- sender
- .table("tableName")
- .stringColumn("name", "value")
- .symbol("symbolName", "symbolValue"),
- ).toThrow(
- "Symbol can be added only after table name is set and before any column added",
- );
- await sender.close();
- });
-
- it("returns null if preparing an empty buffer for send", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(sender.toBufferView()).toBe(null);
- expect(sender.toBufferNew()).toBe(null);
- await sender.close();
- });
-
- it("leaves unfinished rows in the sender's buffer when preparing a copy of the buffer for send", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- sender.table("tableName").symbol("name", "value");
- await sender.at(1234567890n, "ns");
- sender.table("tableName").symbol("name", "value2");
-
- // copy of the sender's buffer contains the finished row
- expect(sender.toBufferNew(sender.endOfLastRow).toString()).toBe(
- "tableName,name=value 1234567890\n",
- );
- // the sender's buffer is compacted, and contains only the unfinished row
- expect(sender.toBufferView().toString()).toBe(
- "tableName,name=value2",
- );
- await sender.close();
- });
-
- it("throws exception if a float is passed as integer field", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- sender.table("tableName").intColumn("intField", 123.222),
- ).toThrow("Value must be an integer, received 123.222");
- await sender.close();
- });
-
- it("throws exception if a float is passed as timestamp field", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- expect(() =>
- sender.table("tableName").timestampColumn("intField", 123.222),
- ).toThrow("Value must be an integer or BigInt, received 123.222");
- await sender.close();
- });
-
- it("throws exception if designated timestamp is not an integer or bigint", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- try {
- await sender
- .table("tableName")
- .symbol("name", "value")
- .at(23232322323.05);
- } catch (e) {
- expect(e.message).toEqual(
- "Designated timestamp must be an integer or BigInt, received 23232322323.05",
- );
- }
- await sender.close();
- });
-
- it("throws exception if designated timestamp is invalid", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- try {
- // @ts-expect-error invalid options
- await sender.table("tableName").symbol("name", "value").at("invalid_dts");
- } catch (e) {
- expect(e.message).toEqual(
- "Designated timestamp must be an integer or BigInt, received invalid_dts",
- );
- }
- await sender.close();
- });
-
- it("throws exception if designated timestamp is set without any fields added", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- try {
- await sender.table("tableName").at(12345678n, "ns");
- } catch (e) {
- expect(e.message).toEqual(
- "The row must have a symbol or column set before it is closed",
- );
- }
- await sender.close();
- });
-
- it("extends the size of the buffer if data does not fit", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 8,
- });
- expect(sender.bufferSize).toBe(8);
- expect(sender.position).toBe(0);
- sender.table("tableName");
- expect(sender.bufferSize).toBe(16);
- expect(sender.position).toBe("tableName".length);
- sender.intColumn("intField", 123);
- expect(sender.bufferSize).toBe(32);
- expect(sender.position).toBe("tableName intField=123i".length);
- await sender.atNow();
- expect(sender.bufferSize).toBe(32);
- expect(sender.position).toBe("tableName intField=123i\n".length);
- expect(sender.toBufferView().toString()).toBe("tableName intField=123i\n");
-
- await sender
- .table("table2")
- .intColumn("intField", 125)
- .stringColumn("strField", "test")
- .atNow();
- expect(sender.bufferSize).toBe(64);
- expect(sender.position).toBe(
- 'tableName intField=123i\ntable2 intField=125i,strField="test"\n'.length,
- );
- expect(sender.toBufferView().toString()).toBe(
- 'tableName intField=123i\ntable2 intField=125i,strField="test"\n',
- );
- await sender.close();
- });
-
- it("throws exception if tries to extend the size of the buffer above max buffer size", async function () {
- const sender = Sender.fromConfig(
- "tcp::addr=host;init_buf_size=8;max_buf_size=48;",
- );
- expect(sender.bufferSize).toBe(8);
- expect(sender.position).toBe(0);
- sender.table("tableName");
- expect(sender.bufferSize).toBe(16);
- expect(sender.position).toBe("tableName".length);
- sender.intColumn("intField", 123);
- expect(sender.bufferSize).toBe(32);
- expect(sender.position).toBe("tableName intField=123i".length);
- await sender.atNow();
- expect(sender.bufferSize).toBe(32);
- expect(sender.position).toBe("tableName intField=123i\n".length);
- expect(sender.toBufferView().toString()).toBe("tableName intField=123i\n");
-
- try {
- await sender
- .table("table2")
- .intColumn("intField", 125)
- .stringColumn("strField", "test")
- .atNow();
- } catch (err) {
- expect(err.message).toBe(
- "Max buffer size is 48 bytes, requested buffer size: 64",
- );
- }
- await sender.close();
- });
-
- it("is possible to clear the buffer by calling reset()", async function () {
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- init_buf_size: 1024,
- });
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- .timestampColumn("timestampCol", 1658484765000000)
- .atNow();
- await sender
- .table("tableName")
- .booleanColumn("boolCol", false)
- .timestampColumn("timestampCol", 1658484766000000)
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName boolCol=t,timestampCol=1658484765000000t\n" +
- "tableName boolCol=f,timestampCol=1658484766000000t\n",
- );
-
- sender.reset();
- await sender
- .table("tableName")
- .floatColumn("floatCol", 1234567890)
- .timestampColumn("timestampCol", 1658484767000000)
- .atNow();
- expect(sender.toBufferView().toString()).toBe(
- "tableName floatCol=1234567890,timestampCol=1658484767000000t\n",
- );
- await sender.close();
- });
-});
-
-describe("Sender tests with containerized QuestDB instance", () => {
- let container: any;
-
- async function query(container: any, query: string) {
- const options = {
- hostname: container.getHost(),
- port: container.getMappedPort(QUESTDB_HTTP_PORT),
- path: `/exec?query=${encodeURIComponent(query)}`,
- method: "GET",
- };
-
- return new Promise((resolve, reject) => {
- const req = http.request(options, (response) => {
- if (response.statusCode === HTTP_OK) {
- const body: Uint8Array[] = [];
- response
- .on("data", (data: Uint8Array) => {
- body.push(data);
- })
- .on("end", () => {
- resolve(JSON.parse(Buffer.concat(body).toString()));
- });
- } else {
- reject(
- new Error(
- `HTTP request failed, statusCode=${response.statusCode}, query=${query}`,
- ),
- );
- }
- });
-
- req.on("error", (error) => {
- reject(error);
- });
-
- req.end();
- });
- }
-
- async function runSelect(container: any, select: string, expectedCount: number, timeout = 60000) {
- const interval = 500;
- const num = timeout / interval;
- let selectResult: any;
- for (let i = 0; i < num; i++) {
- selectResult = await query(container, select);
- if (selectResult && selectResult.count >= expectedCount) {
- return selectResult;
- }
- await sleep(interval);
- }
- throw new Error(
- `Timed out while waiting for ${expectedCount} rows, select='${select}'`,
- );
- }
-
- async function waitForTable(container: any, tableName: string, timeout = 30000) {
- await runSelect(container, `tables() where table_name='${tableName}'`, 1, timeout);
- }
-
- function getFieldsString(schema: any) {
- let fields = "";
- for (const element of schema) {
- fields += `${element.name} ${element.type}, `;
- }
- return fields.substring(0, fields.length - 2);
- }
-
- beforeAll(async () => {
- container = await new GenericContainer("questdb/questdb:nightly")
- .withExposedPorts(QUESTDB_HTTP_PORT, QUESTDB_ILP_PORT)
- .start();
-
- const stream = await container.logs();
- stream
- .on("data", (line: string) => console.log(line))
- .on("err", (line: string) => console.error(line))
- .on("end", () => console.log("Stream closed"));
- }, 3000000);
-
- afterAll(async () => {
- await container.stop();
- });
-
- it("can ingest data via TCP and run queries", async () => {
- const sender = new Sender({
- protocol: "tcp",
- host: container.getHost(),
- port: container.getMappedPort(QUESTDB_ILP_PORT),
- });
- await sender.connect();
-
- const tableName = "test_tcp";
- const schema = [
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- ];
-
- // ingest via client
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", 17.1)
- .at(1658484765000000000n, "ns");
- await sender.flush();
-
- // wait for the table
- await waitForTable(container, tableName)
-
- // query table
- const select1Result = await runSelect(container, tableName, 1);
- expect(select1Result.query).toBe(tableName);
- expect(select1Result.count).toBe(1);
- expect(select1Result.columns).toStrictEqual(schema);
- expect(select1Result.dataset).toStrictEqual([
- ["us", 17.1, "2022-07-22T10:12:45.000000Z"],
- ]);
-
- // ingest via client, add new column
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", 17.3)
- .at(1658484765000666000n, "ns");
- await sender
- .table(tableName)
- .symbol("location", "emea")
- .floatColumn("temperature", 17.4)
- .at(1658484765000999000n, "ns");
- await sender
- .table(tableName)
- .symbol("location", "emea")
- .symbol("city", "london")
- .floatColumn("temperature", 18.8)
- .at(1658484765001234000n, "ns");
- await sender.flush();
-
- // query table
- const select2Result = await runSelect(container, tableName, 4);
- expect(select2Result.query).toBe(tableName);
- expect(select2Result.count).toBe(4);
- expect(select2Result.columns).toStrictEqual([
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- { name: "city", type: "SYMBOL" },
- ]);
- expect(select2Result.dataset).toStrictEqual([
- ["us", 17.1, "2022-07-22T10:12:45.000000Z", null],
- ["us", 17.3, "2022-07-22T10:12:45.000666Z", null],
- ["emea", 17.4, "2022-07-22T10:12:45.000999Z", null],
- ["emea", 18.8, "2022-07-22T10:12:45.001234Z", "london"],
- ]);
-
- await sender.close();
- });
-
- it("can ingest data via HTTP with auto flush rows", async () => {
- const tableName = "test_http_rows";
- const schema = [
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- ];
-
- const sender = Sender.fromConfig(
- `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_interval=0;auto_flush_rows=1`,
- );
-
- // ingest via client
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", 17.1)
- .at(1658484765000000000n, "ns");
-
- // wait for the table
- await waitForTable(container, tableName)
-
- // query table
- const select1Result = await runSelect(container, tableName, 1);
- expect(select1Result.query).toBe(tableName);
- expect(select1Result.count).toBe(1);
- expect(select1Result.columns).toStrictEqual(schema);
- expect(select1Result.dataset).toStrictEqual([
- ["us", 17.1, "2022-07-22T10:12:45.000000Z"],
- ]);
-
- // ingest via client, add new column
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", 17.36)
- .at(1658484765000666000n, "ns");
- await sender
- .table(tableName)
- .symbol("location", "emea")
- .floatColumn("temperature", 17.41)
- .at(1658484765000999000n, "ns");
- await sender
- .table(tableName)
- .symbol("location", "emea")
- .symbol("city", "london")
- .floatColumn("temperature", 18.81)
- .at(1658484765001234000n, "ns");
-
- // query table
- const select2Result = await runSelect(container, tableName, 4);
- expect(select2Result.query).toBe(tableName);
- expect(select2Result.count).toBe(4);
- expect(select2Result.columns).toStrictEqual([
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- { name: "city", type: "SYMBOL" },
- ]);
- expect(select2Result.dataset).toStrictEqual([
- ["us", 17.1, "2022-07-22T10:12:45.000000Z", null],
- ["us", 17.36, "2022-07-22T10:12:45.000666Z", null],
- ["emea", 17.41, "2022-07-22T10:12:45.000999Z", null],
- ["emea", 18.81, "2022-07-22T10:12:45.001234Z", "london"],
- ]);
-
- await sender.close();
- });
-
- it("can ingest data via HTTP with auto flush interval", async () => {
- const tableName = "test_http_interval";
- const schema = [
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- ];
-
- const sender = Sender.fromConfig(
- `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_interval=1;auto_flush_rows=0`,
- );
-
- // wait longer than the set auto flush interval to make sure there is a flush
- await sleep(10);
-
- // ingest via client
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", 17.1)
- .at(1658484765000000000n, "ns");
-
- // wait for the table
- await waitForTable(container, tableName)
-
- // query table
- const select1Result = await runSelect(container, tableName, 1);
- expect(select1Result.query).toBe(tableName);
- expect(select1Result.count).toBe(1);
- expect(select1Result.columns).toStrictEqual(schema);
- expect(select1Result.dataset).toStrictEqual([
- ["us", 17.1, "2022-07-22T10:12:45.000000Z"],
- ]);
-
- // ingest via client, add new column
- await sleep(10);
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", 17.36)
- .at(1658484765000666000n, "ns");
- await sleep(10);
- await sender
- .table(tableName)
- .symbol("location", "emea")
- .floatColumn("temperature", 17.41)
- .at(1658484765000999000n, "ns");
- await sleep(10);
- await sender
- .table(tableName)
- .symbol("location", "emea")
- .symbol("city", "london")
- .floatColumn("temperature", 18.81)
- .at(1658484765001234000n, "ns");
-
- // query table
- const select2Result = await runSelect(container, tableName, 4);
- expect(select2Result.query).toBe(tableName);
- expect(select2Result.count).toBe(4);
- expect(select2Result.columns).toStrictEqual([
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- { name: "city", type: "SYMBOL" },
- ]);
- expect(select2Result.dataset).toStrictEqual([
- ["us", 17.1, "2022-07-22T10:12:45.000000Z", null],
- ["us", 17.36, "2022-07-22T10:12:45.000666Z", null],
- ["emea", 17.41, "2022-07-22T10:12:45.000999Z", null],
- ["emea", 18.81, "2022-07-22T10:12:45.001234Z", "london"],
- ]);
-
- await sender.close();
- });
-
- it("does not duplicate rows if await is missing when calling flush", async () => {
- // setting copyBuffer to make sure promises send data from their own local buffer
- const sender = new Sender({
- protocol: "tcp",
- host: container.getHost(),
- port: container.getMappedPort(QUESTDB_ILP_PORT),
- });
- await sender.connect();
-
- const tableName = "test2";
- const schema = [
- { name: "location", type: "SYMBOL" },
- { name: "temperature", type: "DOUBLE" },
- { name: "timestamp", type: "TIMESTAMP" },
- ];
-
- // ingest via client
- const numOfRows = 100;
- for (let i = 0; i < numOfRows; i++) {
- await sender
- .table(tableName)
- .symbol("location", "us")
- .floatColumn("temperature", i)
- .at(1658484765000000000n, "ns");
- // missing await is intentional
- await sender.flush();
- }
-
- // wait for the table
- await waitForTable(container, tableName)
-
- // query table
- const selectQuery = `${tableName} order by temperature`;
- const selectResult = await runSelect(container, selectQuery, numOfRows);
- expect(selectResult.query).toBe(selectQuery);
- expect(selectResult.count).toBe(numOfRows);
- expect(selectResult.columns).toStrictEqual(schema);
-
- const expectedData: (string | number)[][] = [];
- for (let i = 0; i < numOfRows; i++) {
- expectedData.push(["us", i, "2022-07-22T10:12:45.000000Z"]);
- }
- expect(selectResult.dataset).toStrictEqual(expectedData);
-
- await sender.close();
- });
-
- it("ingests all data without loss under high load with auto-flush", async () => {
- const sender = Sender.fromConfig(
- `tcp::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_ILP_PORT)};auto_flush_rows=5;auto_flush_interval=1`,
- );
- await sender.connect();
-
- const tableName = "test_high_load_autoflush";
- const numOfRows = 1000;
- const promises: Promise[] = [];
-
- for (let i = 0; i < numOfRows; i++) {
- // Not awaiting each .at() call individually to allow them to queue up
- const p = sender
- .table(tableName)
- .intColumn("id", i)
- .at(1658484765000000000n + BigInt(1000 * i), "ns"); // Unique timestamp for each row
- promises.push(p);
- }
-
- // Wait for all .at() calls to complete their processing (including triggering auto-flushes)
- await Promise.all(promises);
-
- // Perform a final flush to ensure any data remaining in the buffer is sent.
- // This will be queued correctly after any ongoing auto-flushes.
- await sender.flush();
-
- // Wait for the table
- await waitForTable(container, tableName)
-
- // Query table and verify count
- const selectQuery = `SELECT id FROM ${tableName}`;
- const selectResult = await runSelect(container, selectQuery, numOfRows);
- expect(selectResult.count).toBe(numOfRows);
-
- // Verify data integrity
- for (let i = 0; i < numOfRows; i++) {
- expect(selectResult.dataset[i][0]).toBe(i);
- }
-
- await sender.close();
- }, 30000); // Increased test timeout for this specific test
-});
diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts
new file mode 100644
index 0000000..3b32fcd
--- /dev/null
+++ b/test/sender.transport.test.ts
@@ -0,0 +1,568 @@
+// @ts-check
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { readFileSync } from "fs";
+import { Agent } from "undici";
+import http from "http";
+
+import { Sender } from "../src";
+import { SenderOptions } from "../src/options";
+import { UndiciTransport } from "../src/transport/http/undici";
+import { HttpTransport } from "../src/transport/http/legacy";
+import { MockProxy } from "./util/mockproxy";
+import { MockHttp } from "./util/mockhttp";
+
+const MOCK_HTTP_PORT = 9099;
+const MOCK_HTTPS_PORT = 9098;
+const PROXY_PORT = 9088;
+const PROXY_HOST = "localhost";
+
+const proxyOptions = {
+ key: readFileSync("test/certs/server/server.key"),
+ cert: readFileSync("test/certs/server/server.crt"),
+ ca: readFileSync("test/certs/ca/ca.crt"),
+};
+
+const USER_NAME = "testapp";
+const PRIVATE_KEY = "9b9x5WhJywDEuo1KGQWSPNxtX-6X6R2BRCKhYMMY6n8";
+const AUTH: SenderOptions["auth"] = {
+ keyId: USER_NAME,
+ token: PRIVATE_KEY,
+};
+
+async function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe("Sender HTTP suite", function () {
+ async function sendData(sender: Sender) {
+ await sender
+ .table("test")
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.1)
+ .at(1658484765000000000n, "ns");
+ await sender.flush();
+ }
+
+ const mockHttp = new MockHttp();
+ const mockHttps = new MockHttp();
+
+ beforeAll(async function () {
+ await mockHttp.start(MOCK_HTTP_PORT);
+ await mockHttps.start(MOCK_HTTPS_PORT, true, proxyOptions);
+ });
+
+ afterAll(async function () {
+ await mockHttp.stop();
+ await mockHttps.stop();
+ });
+
+ it("can ingest via HTTP", async function () {
+ mockHttp.reset();
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ );
+ await sendData(sender);
+ expect(mockHttp.numOfRequests).toBe(1);
+
+ await sender.close();
+ });
+
+ it("can ingest via HTTPS", async function () {
+ mockHttps.reset();
+
+ const senderCertCheckFail = Sender.fromConfig(
+ `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT}`,
+ );
+ await expect(sendData(senderCertCheckFail)).rejects.toThrowError(
+ "self-signed certificate in certificate chain",
+ );
+ await senderCertCheckFail.close();
+
+ const senderWithCA = Sender.fromConfig(
+ `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};tls_ca=test/certs/ca/ca.crt`,
+ );
+ await sendData(senderWithCA);
+ expect(mockHttps.numOfRequests).toEqual(1);
+ await senderWithCA.close();
+
+ const senderVerifyOff = Sender.fromConfig(
+ `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
+ );
+ await sendData(senderVerifyOff);
+ expect(mockHttps.numOfRequests).toEqual(2);
+ await senderVerifyOff.close();
+ }, 20000);
+
+ it("can ingest via HTTP with basic auth", async function () {
+ mockHttp.reset({ username: "user1", password: "pwd" });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1;password=pwd`,
+ );
+ await sendData(sender);
+ expect(mockHttp.numOfRequests).toEqual(1);
+ await sender.close();
+
+ const senderFailPwd = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1;password=xyz`,
+ );
+ await expect(sendData(senderFailPwd)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailPwd.close();
+
+ const senderFailMissingPwd = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1z`,
+ );
+ await expect(sendData(senderFailMissingPwd)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailMissingPwd.close();
+
+ const senderFailUsername = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=xyz;password=pwd`,
+ );
+ await expect(sendData(senderFailUsername)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailUsername.close();
+
+ const senderFailMissingUsername = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};password=pwd`,
+ );
+ await expect(sendData(senderFailMissingUsername)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailMissingUsername.close();
+
+ const senderFailMissing = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ );
+ await expect(sendData(senderFailMissing)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailMissing.close();
+ });
+
+ it("can ingest via HTTP with token auth", async function () {
+ mockHttp.reset({ token: "abcdefghijkl123" });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};token=abcdefghijkl123`,
+ );
+ await sendData(sender);
+ expect(mockHttp.numOfRequests).toBe(1);
+ await sender.close();
+
+ const senderFailToken = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};token=xyz`,
+ );
+ await expect(sendData(senderFailToken)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailToken.close();
+
+ const senderFailMissing = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ );
+ await expect(sendData(senderFailMissing)).rejects.toThrowError(
+ "HTTP request failed, statusCode=401",
+ );
+ await senderFailMissing.close();
+ });
+
+ it("can retry via HTTP", async function () {
+ mockHttp.reset({ responseCodes: [204, 500, 523, 504, 500] });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ );
+ await sendData(sender);
+ expect(mockHttp.numOfRequests).toBe(5);
+
+ await sender.close();
+ });
+
+ it("fails when retry timeout expires", async function () {
+ // artificial delay (responseDelays) is the same as retry timeout,
+ // this should result in the request failing on the second try.
+ mockHttp.reset({
+ responseCodes: [204, 500, 503],
+ responseDelays: [1000, 1000, 1000],
+ });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=1000`,
+ );
+ await expect(sendData(sender)).rejects.toThrowError(
+ "HTTP request timeout, no response from server in time"
+ );
+ await sender.close();
+ });
+
+ it("fails when HTTP request times out", async function () {
+ // artificial delay (responseDelays) is greater than request timeout, and retry is switched off
+ // should result in the request failing with timeout
+ mockHttp.reset({
+ responseCodes: [204],
+ responseDelays: [1000],
+ });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=0;request_timeout=100`,
+ );
+ await expect(sendData(sender)).rejects.toThrowError(
+ "HTTP request timeout, no response from server in time",
+ );
+ await sender.close();
+ });
+
+ it("succeeds on the third request after two timeouts", async function () {
+ mockHttp.reset({
+ responseCodes: [204, 504, 504],
+ responseDelays: [2000, 2000],
+ });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=600000;request_timeout=1000`,
+ );
+ await sendData(sender);
+
+ await sender.close();
+ });
+
+ it("multiple senders can use a single HTTP agent", async function () {
+ mockHttp.reset();
+ const agent = new Agent({ connect: { keepAlive: false } });
+
+ const num = 300;
+ const senders: Sender[] = [];
+ const promises: Promise[] = [];
+ for (let i = 0; i < num; i++) {
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ { agent: agent },
+ );
+ senders.push(sender);
+ const promise = sendData(sender);
+ promises.push(promise);
+ }
+ await Promise.all(promises);
+ expect(mockHttp.numOfRequests).toBe(num);
+
+ for (const sender of senders) {
+ await sender.close();
+ }
+ await agent.destroy();
+ });
+
+ it("supports custom Undici HTTP agent", async function () {
+ mockHttp.reset();
+ const agent = new Agent({ pipelining: 3 });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ { agent: agent },
+ );
+
+ await sendData(sender);
+ expect(mockHttp.numOfRequests).toBe(1);
+
+ // @ts-expect-error - Accessing private field
+ const senderAgent = (sender.transport as UndiciTransport).agent;
+ const symbols = Object.getOwnPropertySymbols(senderAgent);
+ expect(senderAgent[symbols[6]]).toEqual({ pipelining: 3 });
+
+ await sender.close();
+ await agent.destroy();
+ });
+
+ it('supports custom legacy HTTP agent', async function () {
+ mockHttp.reset();
+ const agent = new http.Agent({ maxSockets: 128 });
+
+ const sender = Sender.fromConfig(
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};legacy_http=on`,
+ { agent: agent },
+ );
+ await sendData(sender);
+ expect(mockHttp.numOfRequests).toBe(1);
+
+ // @ts-expect-error - Accessing private field
+ const senderAgent = (sender.transport as HttpTransport).agent;
+ expect(senderAgent.maxSockets).toBe(128);
+
+ await sender.close();
+ agent.destroy();
+ });
+});
+
+describe("Sender TCP suite", function () {
+ async function createProxy(
+ auth = false,
+ tlsOptions?: Record,
+ ) {
+ const mockConfig = { auth: auth, assertions: true };
+ const proxy = new MockProxy(mockConfig);
+ await proxy.start(PROXY_PORT, tlsOptions);
+ expect(proxy.mockConfig).toBe(mockConfig);
+ expect(proxy.dataSentToRemote).toStrictEqual([]);
+ return proxy;
+ }
+
+ async function createSender(auth: SenderOptions["auth"], secure = false) {
+ const sender = new Sender({
+ protocol: secure ? "tcps" : "tcp",
+ port: PROXY_PORT,
+ host: PROXY_HOST,
+ auth: auth,
+ tls_ca: "test/certs/ca/ca.crt",
+ });
+ const connected = await sender.connect();
+ expect(connected).toBe(true);
+ return sender;
+ }
+
+ async function sendData(sender: Sender) {
+ await sender
+ .table("test")
+ .symbol("location", "us")
+ .floatColumn("temperature", 17.1)
+ .at(1658484765000000000n, "ns");
+ await sender.flush();
+ }
+
+ async function assertSentData(
+ proxy: MockProxy,
+ authenticated: boolean,
+ expected: string,
+ timeout = 60000,
+ ) {
+ const interval = 100;
+ const num = timeout / interval;
+ let actual: string;
+ for (let i = 0; i < num; i++) {
+ const dataSentToRemote = proxy.getDataSentToRemote().join("").split("\n");
+ if (authenticated) {
+ dataSentToRemote.splice(1, 1);
+ }
+ actual = dataSentToRemote.join("\n");
+ if (actual === expected) {
+ return new Promise((resolve) => resolve(null));
+ }
+ await sleep(interval);
+ }
+ return new Promise((resolve) =>
+ resolve(`data assert failed [expected=${expected}, actual=${actual}]`),
+ );
+ }
+
+ it("can authenticate", async function () {
+ const proxy = await createProxy(true);
+ const sender = await createSender(AUTH);
+ await assertSentData(proxy, true, "testapp\n");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can authenticate with a different private key", async function () {
+ const proxy = await createProxy(true);
+ const sender = await createSender({
+ keyId: "user1",
+ token: "zhPiK3BkYMYJvRf5sqyrWNJwjDKHOWHnRbmQggUll6A",
+ });
+ await assertSentData(proxy, true, "user1\n");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("is backwards compatible and still can authenticate with full JWK", async function () {
+ const JWK = {
+ x: "BtUXC_K3oAyGlsuPjTgkiwirMUJhuRQDfcUHeyoxFxU",
+ y: "R8SOup-rrNofB7wJagy4HrJhTVfrVKmj061lNRk3bF8",
+ kid: "user2",
+ kty: "EC",
+ d: "hsg6Zm4kSBlIEvKUWT3kif-2y2Wxw-iWaGrJxrPXQhs",
+ crv: "P-256",
+ };
+
+ const proxy = await createProxy(true);
+ const sender = new Sender({
+ protocol: "tcp",
+ port: PROXY_PORT,
+ host: PROXY_HOST,
+ jwk: JWK,
+ });
+ const connected = await sender.connect();
+ expect(connected).toBe(true);
+ await assertSentData(proxy, true, "user2\n");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can connect unauthenticated", async function () {
+ const proxy = await createProxy();
+ // @ts-expect-error - Invalid options
+ const sender = await createSender();
+ await assertSentData(proxy, false, "");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can authenticate and send data to server", async function () {
+ const proxy = await createProxy(true);
+ const sender = await createSender(AUTH);
+ await sendData(sender);
+ await assertSentData(
+ proxy,
+ true,
+ "testapp\ntest,location=us temperature=17.1 1658484765000000000\n",
+ );
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can connect unauthenticated and send data to server", async function () {
+ const proxy = await createProxy();
+ // @ts-expect-error - Invalid options
+ const sender = await createSender();
+ await sendData(sender);
+ await assertSentData(
+ proxy,
+ false,
+ "test,location=us temperature=17.1 1658484765000000000\n",
+ );
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can authenticate and send data to server via secure connection", async function () {
+ const proxy = await createProxy(true, proxyOptions);
+ const sender = await createSender(AUTH, true);
+ await sendData(sender);
+ await assertSentData(
+ proxy,
+ true,
+ "testapp\ntest,location=us temperature=17.1 1658484765000000000\n",
+ );
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can connect unauthenticated and send data to server via secure connection", async function () {
+ const proxy = await createProxy(false, proxyOptions);
+ const sender = await createSender(null, true);
+ await sendData(sender);
+ await assertSentData(
+ proxy,
+ false,
+ "test,location=us temperature=17.1 1658484765000000000\n",
+ );
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("fails to connect without hostname and port", async function () {
+ await expect(async () =>
+ await new Sender({ protocol: "tcp" }).close()
+ ).rejects.toThrow("The 'host' option is mandatory");
+ });
+
+ it("fails to send data if not connected", async function () {
+ const sender = new Sender({ protocol: "tcp", host: "localhost" });
+ await expect(async () => {
+ await sender.table("test").symbol("location", "us").atNow();
+ await sender.flush();
+ }).rejects.toThrow("TCP transport is not connected");
+ await sender.close();
+ });
+
+ it("guards against multiple connect calls", async function () {
+ const proxy = await createProxy(true, proxyOptions);
+ const sender = await createSender(AUTH, true);
+ await expect(async () =>
+ await sender.connect()
+ ).rejects.toThrow("Sender connected already");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("guards against concurrent connect calls", async function () {
+ const proxy = await createProxy(true, proxyOptions);
+ const sender = new Sender({
+ protocol: "tcps",
+ port: PROXY_PORT,
+ host: PROXY_HOST,
+ auth: AUTH,
+ tls_ca: "test/certs/ca/ca.crt",
+ });
+ await expect(async () =>
+ await Promise.all([sender.connect(), sender.connect()])
+ ).rejects.toThrow("Sender connected already");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("can disable the server certificate check", async function () {
+ const proxy = await createProxy(true, proxyOptions);
+ const senderCertCheckFail = Sender.fromConfig(
+ `tcps::addr=${PROXY_HOST}:${PROXY_PORT}`,
+ );
+ await expect(async () =>
+ await senderCertCheckFail.connect()
+ ).rejects.toThrow("self-signed certificate in certificate chain");
+ await senderCertCheckFail.close();
+
+ const senderCertCheckOn = Sender.fromConfig(
+ `tcps::addr=${PROXY_HOST}:${PROXY_PORT};tls_ca=test/certs/ca/ca.crt`,
+ );
+ await senderCertCheckOn.connect();
+ await senderCertCheckOn.close();
+
+ const senderCertCheckOff = Sender.fromConfig(
+ `tcps::addr=${PROXY_HOST}:${PROXY_PORT};tls_verify=unsafe_off`,
+ );
+ await senderCertCheckOff.connect();
+ await senderCertCheckOff.close();
+ await proxy.stop();
+ });
+
+ it("can handle unfinished rows during flush()", async function () {
+ const proxy = await createProxy(true, proxyOptions);
+ const sender = await createSender(AUTH, true);
+ sender.table("test").symbol("location", "us");
+ const sent = await sender.flush();
+ expect(sent).toBe(false);
+ await assertSentData(proxy, true, "testapp\n");
+ await sender.close();
+ await proxy.stop();
+ });
+
+ it("supports custom logger", async function () {
+ const expectedMessages = [
+ "Successfully connected to localhost:9088",
+ /^Connection to .*1:9088 is closed$/,
+ ];
+ const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
+ expect(level).toBe("info");
+ expect(message).toMatch(expectedMessages.shift());
+ };
+ const proxy = await createProxy();
+ const sender = new Sender({
+ protocol: "tcp",
+ port: PROXY_PORT,
+ host: PROXY_HOST,
+ log: log,
+ });
+ await sender.connect();
+ await sendData(sender);
+ await assertSentData(
+ proxy,
+ false,
+ "test,location=us temperature=17.1 1658484765000000000\n",
+ );
+ await sender.close();
+ await proxy.stop();
+ });
+});
diff --git a/test/testapp.ts b/test/testapp.ts
index 9aedd9c..39f1267 100644
--- a/test/testapp.ts
+++ b/test/testapp.ts
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
-import { Proxy } from "./_utils_/proxy";
+import { Proxy } from "./util/proxy";
import { Sender } from "../src";
import { SenderOptions } from "../src/options";
@@ -8,13 +8,6 @@ const PROXY_PORT = 9099;
const PORT = 9009;
const HOST = "localhost";
-const USER_NAME = "testapp";
-const PRIVATE_KEY = "9b9x5WhJywDEuo1KGQWSPNxtX-6X6R2BRCKhYMMY6n8";
-const AUTH = {
- kid: USER_NAME,
- d: PRIVATE_KEY,
-};
-
const senderOptions: SenderOptions = {
protocol: "tcps",
host: HOST,
diff --git a/test/_utils_/mockhttp.ts b/test/util/mockhttp.ts
similarity index 74%
rename from test/_utils_/mockhttp.ts
rename to test/util/mockhttp.ts
index 8487bc0..2341df6 100644
--- a/test/_utils_/mockhttp.ts
+++ b/test/util/mockhttp.ts
@@ -1,15 +1,17 @@
import http from "node:http";
import https from "node:https";
+type MockConfig = {
+ responseDelays?: number[],
+ responseCodes?: number[],
+ username?: string,
+ password?: string,
+ token?: string,
+}
+
class MockHttp {
server: http.Server | https.Server;
- mockConfig: {
- responseDelays?: number[],
- responseCodes?: number[],
- username?: string,
- password?: string,
- token?: string,
- };
+ mockConfig: MockConfig;
numOfRequests: number;
constructor() {
@@ -21,10 +23,10 @@ class MockHttp {
this.numOfRequests = 0;
}
- async start(listenPort: number, secure: boolean = false, options?: Record) {
+ async start(listenPort: number, secure: boolean = false, options?: Record): Promise {
const serverCreator = secure ? https.createServer : http.createServer;
// @ts-expect-error - Testing different options, so typing is not important
- this.server = serverCreator(options, (req, res) => {
+ this.server = serverCreator(options, (req: http.IncomingMessage, res: http.ServerResponse) => {
const authFailed = checkAuthHeader(this.mockConfig, req);
const body: Uint8Array[] = [];
@@ -56,8 +58,16 @@ class MockHttp {
});
});
- this.server.listen(listenPort, () => {
- console.info(`Server is running on port ${listenPort}`);
+ return new Promise((resolve, reject) => {
+ this.server.listen(listenPort, () => {
+ console.info(`Server is running on port ${listenPort}`);
+ resolve(true);
+ });
+
+ this.server.on("error", e => {
+ console.error(`server error: ${e}`);
+ reject(e);
+ });
});
}
@@ -68,7 +78,7 @@ class MockHttp {
}
}
-function checkAuthHeader(mockConfig, req) {
+function checkAuthHeader(mockConfig: MockConfig, req: http.IncomingMessage) {
let authFailed = false;
const header = (req.headers.authorization || "").split(/\s+/);
switch (header[0]) {
@@ -92,7 +102,7 @@ function checkAuthHeader(mockConfig, req) {
return authFailed;
}
-function sleep(ms) {
+function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
diff --git a/test/_utils_/mockproxy.ts b/test/util/mockproxy.ts
similarity index 78%
rename from test/_utils_/mockproxy.ts
rename to test/util/mockproxy.ts
index f26e404..ecc9c9c 100644
--- a/test/_utils_/mockproxy.ts
+++ b/test/util/mockproxy.ts
@@ -1,17 +1,22 @@
+import net, { Socket } from "node:net";
+import tls from "node:tls";
import { write, listen, shutdown } from "./proxyfunctions";
const CHALLENGE_LENGTH = 512;
+type MockConfig = {
+ auth?: boolean;
+ assertions?: boolean;
+}
+
class MockProxy {
- mockConfig: {
- auth?: boolean;
- assertions?: boolean;
- };
+ mockConfig: MockConfig;
dataSentToRemote: string[];
hasSentChallenge: boolean;
- client: unknown;
+ client: Socket;
+ server: net.Server | tls.Server
- constructor(mockConfig) {
+ constructor(mockConfig: MockConfig) {
if (!mockConfig) {
throw new Error("Missing mock config");
}
@@ -19,7 +24,7 @@ class MockProxy {
this.dataSentToRemote = [];
}
- async start(listenPort: number, tlsOptions?: Record) {
+ async start(listenPort: number, tlsOptions?: tls.TlsOptions) {
await listen(
this,
listenPort,
diff --git a/test/_utils_/proxy.ts b/test/util/proxy.ts
similarity index 78%
rename from test/_utils_/proxy.ts
rename to test/util/proxy.ts
index 00394bf..ca9a581 100644
--- a/test/_utils_/proxy.ts
+++ b/test/util/proxy.ts
@@ -1,17 +1,19 @@
-import { Socket } from "node:net";
+import net, { Socket } from "node:net";
+import tls from "node:tls";
import { write, listen, shutdown, connect, close } from "./proxyfunctions";
// handles only a single client
// client -> server (Proxy) -> remote (QuestDB)
// client <- server (Proxy) <- remote (QuestDB)
class Proxy {
- client: unknown;
+ client: Socket;
remote: Socket;
+ server: net.Server | tls.Server
constructor() {
this.remote = new Socket();
- this.remote.on("data", async (data: unknown) => {
+ this.remote.on("data", async (data: string) => {
console.info(`received from remote, forwarding to client: ${data}`);
await write(this.client, data);
});
@@ -25,14 +27,14 @@ class Proxy {
});
}
- async start(listenPort: unknown, remotePort: unknown, remoteHost: unknown, tlsOptions: Record) {
+ async start(listenPort: number, remotePort: number, remoteHost: string, tlsOptions: Record) {
return new Promise((resolve) => {
this.remote.on("ready", async () => {
console.info("remote connection ready");
await listen(
this,
listenPort,
- async (data: unknown) => {
+ async (data: string) => {
console.info(`received from client, forwarding to remote: ${data}`);
await write(this.remote, data);
},
diff --git a/test/_utils_/proxyfunctions.ts b/test/util/proxyfunctions.ts
similarity index 66%
rename from test/_utils_/proxyfunctions.ts
rename to test/util/proxyfunctions.ts
index 0d5fab9..bc23f18 100644
--- a/test/_utils_/proxyfunctions.ts
+++ b/test/util/proxyfunctions.ts
@@ -1,23 +1,19 @@
-import net from "node:net";
-import tls from "node:tls";
+import net, { Socket } from "node:net";
+import tls, { TLSSocket } from "node:tls";
+import { Proxy } from "./proxy";
+import {MockProxy} from "./mockproxy";
const LOCALHOST = "localhost";
-async function write(socket, data) {
+async function write(socket: Socket, data: string) {
return new Promise((resolve, reject) => {
- socket.write(data, "utf8", (err) => {
- if (err) {
- reject(err)
- } else {
- resolve();
- }
- });
+ socket.write(data, "utf8", (err: Error) => err ? reject(err): resolve());
});
}
-async function listen(proxy, listenPort, dataHandler, tlsOptions) {
+async function listen(proxy: Proxy | MockProxy, listenPort: number, dataHandler: (data: string) => void, tlsOptions: tls.TlsOptions) {
return new Promise((resolve) => {
- const clientConnHandler = (client) => {
+ const clientConnHandler = (client: Socket | TLSSocket) => {
console.info("client connected");
if (proxy.client) {
console.error("There is already a client connected");
@@ -43,7 +39,7 @@ async function listen(proxy, listenPort, dataHandler, tlsOptions) {
});
}
-async function shutdown(proxy, onServerClose = async () => { }) {
+async function shutdown(proxy: Proxy | MockProxy, onServerClose = async () => {}) {
console.info("closing proxy");
return new Promise((resolve) => {
proxy.server.close(async () => {
@@ -53,7 +49,7 @@ async function shutdown(proxy, onServerClose = async () => { }) {
});
}
-async function connect(proxy, remotePort, remoteHost) {
+async function connect(proxy: Proxy, remotePort: number, remoteHost: string) {
console.info(`opening remote connection to ${remoteHost}:${remotePort}`);
return new Promise((resolve) => {
proxy.remote.connect(remotePort, remoteHost, () => resolve());
From 7efb5ec72f18cd9a984cd05a6876a749eabb050a Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 16 Jul 2025 19:56:32 +0100
Subject: [PATCH 02/38] make linter happy
---
src/options.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/options.ts b/src/options.ts
index ee39178..2ee91f1 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -193,7 +193,7 @@ class SenderOptions {
extraOptions.agent
&& !(extraOptions.agent instanceof Agent)
&& !(extraOptions.agent instanceof http.Agent)
- // @ts-ignore
+ // @ts-expect-error - Not clear what the problem is, the two lines above have no issues
&& !(extraOptions.agent instanceof https.Agent)
) {
throw new Error("Invalid HTTP agent");
From 0ddf668031019645baccb9dc095121c6ed67dd0e Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 16 Jul 2025 23:46:09 +0100
Subject: [PATCH 03/38] more type fixes
---
src/options.ts | 6 +++---
src/sender.ts | 8 ++++----
test/sender.integration.test.ts | 10 +++++-----
test/util/proxy.ts | 2 +-
4 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/src/options.ts b/src/options.ts
index 2ee91f1..abb5e1a 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -180,7 +180,7 @@ class SenderOptions {
* - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
* A http.Agent or https.Agent object is expected.
*/
- constructor(configurationString: string, extraOptions: ExtraOptions = undefined) {
+ constructor(configurationString: string, extraOptions?: ExtraOptions) {
parseConfigurationString(this, configurationString);
if (extraOptions) {
@@ -231,7 +231,7 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
*/
- static fromConfig(configurationString: string, extraOptions: ExtraOptions = undefined): SenderOptions {
+ static fromConfig(configurationString: string, extraOptions?: ExtraOptions): SenderOptions {
return new SenderOptions(configurationString, extraOptions);
}
@@ -246,7 +246,7 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.
*/
- static fromEnv(extraOptions: ExtraOptions = undefined): SenderOptions {
+ static fromEnv(extraOptions?: ExtraOptions): SenderOptions {
return SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions);
}
}
diff --git a/src/sender.ts b/src/sender.ts
index 9ad0786..fe90641 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -128,7 +128,7 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the provided configuration string.
*/
- static fromConfig(configurationString: string, extraOptions: ExtraOptions = undefined): Sender {
+ static fromConfig(configurationString: string, extraOptions?: ExtraOptions): Sender {
return new Sender(
SenderOptions.fromConfig(configurationString, extraOptions),
);
@@ -145,7 +145,7 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the QDB_CLIENT_CONF environment variable.
*/
- static fromEnv(extraOptions: ExtraOptions = undefined): Sender {
+ static fromEnv(extraOptions?: ExtraOptions): Sender {
return new Sender(
SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions),
);
@@ -488,12 +488,12 @@ class Sender {
name: string,
value: unknown,
writeValue: () => void,
- valueType?: string | null,
+ valueType?: string
) {
if (typeof name !== "string") {
throw new Error(`Column name must be a string, received ${typeof name}`);
}
- if (valueType != null && typeof value !== valueType) {
+ if (valueType && typeof value !== valueType) {
throw new Error(
`Column value must be of type ${valueType}, received ${typeof value}`,
);
diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts
index cc9e410..aa2b5ca 100644
--- a/test/sender.integration.test.ts
+++ b/test/sender.integration.test.ts
@@ -1,6 +1,6 @@
// @ts-check
import { describe, it, expect, beforeAll, afterAll } from "vitest";
-import { GenericContainer } from "testcontainers";
+import { GenericContainer, StartedTestContainer } from "testcontainers";
import http from "http";
import { Sender } from "../src";
@@ -15,9 +15,9 @@ async function sleep(ms: number) {
}
describe("Sender tests with containerized QuestDB instance", () => {
- let container: any;
+ let container: StartedTestContainer;
- async function query(container: any, query: string) {
+ async function query(container: StartedTestContainer, query: string) {
const options: http.RequestOptions = {
hostname: container.getHost(),
port: container.getMappedPort(QUESTDB_HTTP_PORT),
@@ -46,7 +46,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
});
}
- async function runSelect(container: any, select: string, expectedCount: number, timeout = 60000) {
+ async function runSelect(container: StartedTestContainer, select: string, expectedCount: number, timeout = 60000) {
const interval = 500;
const num = timeout / interval;
let selectResult: any;
@@ -62,7 +62,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
);
}
- async function waitForTable(container: any, tableName: string, timeout = 30000) {
+ async function waitForTable(container: StartedTestContainer, tableName: string, timeout = 30000) {
await runSelect(container, `tables() where table_name='${tableName}'`, 1, timeout);
}
diff --git a/test/util/proxy.ts b/test/util/proxy.ts
index ca9a581..59c3076 100644
--- a/test/util/proxy.ts
+++ b/test/util/proxy.ts
@@ -22,7 +22,7 @@ class Proxy {
console.info("remote connection closed");
});
- this.remote.on("error", (err: unknown) => {
+ this.remote.on("error", (err: Error) => {
console.error(`remote connection: ${err}`);
});
}
From eb0d7ee02b1b16603d8cd5fb595e02c9f884a1e8 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 17 Jul 2025 03:10:46 +0100
Subject: [PATCH 04/38] extract buffer from sender
---
src/buffer/index.ts | 414 ++++++++++++++++++++++++++++++++++
src/options.ts | 4 +
src/sender.ts | 330 +++------------------------
test/sender.buffer.test.ts | 126 +++++------
test/sender.config.test.ts | 86 +++----
test/sender.transport.test.ts | 6 +-
6 files changed, 568 insertions(+), 398 deletions(-)
create mode 100644 src/buffer/index.ts
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
new file mode 100644
index 0000000..eb8f0a0
--- /dev/null
+++ b/src/buffer/index.ts
@@ -0,0 +1,414 @@
+// @ts-check
+import { Buffer } from "node:buffer";
+
+import { log, Logger } from "../logging";
+import { validateColumnName, validateTableName } from "../validation";
+import { SenderOptions } from "../options";
+import { isInteger, timestampToMicros, timestampToNanos } from "../utils";
+
+const DEFAULT_MAX_NAME_LENGTH = 127;
+
+const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
+const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
+
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * If no custom agent is configured, the Sender will use its own agent which overrides some default values
+ * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
+ *
+ */
+class SenderBuffer {
+ private bufferSize: number;
+ private readonly maxBufferSize: number;
+ private buffer: Buffer;
+ private position: number;
+ private endOfLastRow: number;
+
+ private hasTable: boolean;
+ private hasSymbols: boolean;
+ private hasColumns: boolean;
+
+ private readonly maxNameLength: number;
+
+ private readonly log: Logger;
+
+ /**
+ * Creates an instance of Sender.
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
+ */
+ constructor(options: SenderOptions) {
+ this.log = options && typeof options.log === "function" ? options.log : log;
+ SenderOptions.resolveDeprecated(options, this.log);
+
+ this.maxNameLength = options && isInteger(options.max_name_len, 1)
+ ? options.max_name_len
+ : DEFAULT_MAX_NAME_LENGTH;
+
+ this.maxBufferSize = options && isInteger(options.max_buf_size, 1)
+ ? options.max_buf_size
+ : DEFAULT_MAX_BUFFER_SIZE;
+ this.resize(
+ options && isInteger(options.init_buf_size, 1)
+ ? options.init_buf_size
+ : DEFAULT_BUFFER_SIZE,
+ );
+
+ this.reset();
+ }
+
+ /**
+ * Extends the size of the sender's buffer.
+ * Can be used to increase the size of buffer if overflown.
+ * The buffer's content is copied into the new buffer.
+ *
+ * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
+ */
+ private resize(bufferSize: number) {
+ if (bufferSize > this.maxBufferSize) {
+ throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
+ }
+ this.bufferSize = bufferSize;
+ // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
+ // longer than the size of the buffer. It simply just writes whatever it can, and returns.
+ // If we can write into the extra byte, that indicates buffer overflow.
+ // See the check in the write() function.
+ const newBuffer = Buffer.alloc(this.bufferSize + 1, 0, "utf8");
+ if (this.buffer) {
+ this.buffer.copy(newBuffer);
+ }
+ this.buffer = newBuffer;
+ }
+
+ /**
+ * Resets the buffer, data added to the buffer will be lost.
+ * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ *
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ reset(): SenderBuffer {
+ this.position = 0;
+ this.startNewRow();
+ return this;
+ }
+
+ private startNewRow() {
+ this.endOfLastRow = this.position;
+ this.hasTable = false;
+ this.hasSymbols = false;
+ this.hasColumns = false;
+ }
+
+ /**
+ * @ignore
+ * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
+ * The returned buffer is backed by the sender's buffer.
+ * Used only in tests.
+ */
+ toBufferView(pos = this.endOfLastRow): Buffer {
+ return pos > 0 ? this.buffer.subarray(0, pos) : null;
+ }
+
+ /**
+ * @ignore
+ * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
+ * The returned buffer is a copy of the sender's buffer.
+ * It also compacts the Sender's buffer.
+ */
+ toBufferNew(pos = this.endOfLastRow): Buffer | null {
+ if (pos > 0) {
+ const data = Buffer.allocUnsafe(pos);
+ this.buffer.copy(data, 0, 0, pos);
+ this.compact();
+ return data;
+ }
+ return null;
+ }
+
+ /**
+ * Write the table name into the buffer of the sender.
+ *
+ * @param {string} table - Table name.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ table(table: string): SenderBuffer {
+ if (typeof table !== "string") {
+ throw new Error(`Table name must be a string, received ${typeof table}`);
+ }
+ if (this.hasTable) {
+ throw new Error("Table name has already been set");
+ }
+ validateTableName(table, this.maxNameLength);
+ this.checkCapacity([table], table.length);
+ this.writeEscaped(table);
+ this.hasTable = true;
+ return this;
+ }
+
+ /**
+ * Write a symbol name and value into the buffer of the sender.
+ *
+ * @param {string} name - Symbol name.
+ * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ symbol(name: string, value: unknown): SenderBuffer {
+ if (typeof name !== "string") {
+ throw new Error(`Symbol name must be a string, received ${typeof name}`);
+ }
+ if (!this.hasTable || this.hasColumns) {
+ throw new Error("Symbol can be added only after table name is set and before any column added");
+ }
+ const valueStr = value.toString();
+ this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length);
+ this.write(",");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ this.writeEscaped(valueStr);
+ this.hasSymbols = true;
+ return this;
+ }
+
+ /**
+ * Write a string column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {string} value - Column value, accepts only string values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ stringColumn(name: string, value: string): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ this.checkCapacity([value], 2 + value.length);
+ this.write('"');
+ this.writeEscaped(value, true);
+ this.write('"');
+ },
+ "string",
+ );
+ return this;
+ }
+
+ /**
+ * Write a boolean column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {boolean} value - Column value, accepts only boolean values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ booleanColumn(name: string, value: boolean): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ this.checkCapacity([], 1);
+ this.write(value ? "t" : "f");
+ },
+ "boolean",
+ );
+ return this;
+ }
+
+ /**
+ * Write a float column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ floatColumn(name: string, value: number): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ const valueStr = value.toString();
+ this.checkCapacity([valueStr]);
+ this.write(valueStr);
+ },
+ "number",
+ );
+ return this;
+ }
+
+ /**
+ * Write an integer column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ intColumn(name: string, value: number): SenderBuffer {
+ if (!Number.isInteger(value)) {
+ throw new Error(`Value must be an integer, received ${value}`);
+ }
+ this.writeColumn(name, value, () => {
+ const valueStr = value.toString();
+ this.checkCapacity([valueStr], 1);
+ this.write(valueStr);
+ this.write("i");
+ });
+ return this;
+ }
+
+ /**
+ * Write a timestamp column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
+ * @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ timestampColumn(
+ name: string,
+ value: number | bigint,
+ unit: "ns" | "us" | "ms" = "us",
+ ): SenderBuffer {
+ if (typeof value !== "bigint" && !Number.isInteger(value)) {
+ throw new Error(`Value must be an integer or BigInt, received ${value}`);
+ }
+ this.writeColumn(name, value, () => {
+ const valueMicros = timestampToMicros(BigInt(value), unit);
+ const valueStr = valueMicros.toString();
+ this.checkCapacity([valueStr], 1);
+ this.write(valueStr);
+ this.write("t");
+ });
+ return this;
+ }
+
+ /**
+ * Closing the row after writing the designated timestamp into the buffer of the sender.
+ *
+ * @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
+ * @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
+ */
+ at(timestamp: number | bigint, unit: "ns" | "us" | "ms" = "us") {
+ if (!this.hasSymbols && !this.hasColumns) {
+ throw new Error("The row must have a symbol or column set before it is closed");
+ }
+ if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
+ throw new Error(`Designated timestamp must be an integer or BigInt, received ${timestamp}`);
+ }
+ const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
+ const timestampStr = timestampNanos.toString();
+ this.checkCapacity([timestampStr], 2);
+ this.write(" ");
+ this.write(timestampStr);
+ this.write("\n");
+ this.startNewRow();
+ }
+
+ /**
+ * Closing the row without writing designated timestamp into the buffer of the sender.
+ * Designated timestamp will be populated by the server on this record.
+ */
+ atNow() {
+ if (!this.hasSymbols && !this.hasColumns) {
+ throw new Error("The row must have a symbol or column set before it is closed");
+ }
+ this.checkCapacity([], 1);
+ this.write("\n");
+ this.startNewRow();
+ }
+
+ private checkCapacity(data: string[], base = 0) {
+ let length = base;
+ for (const str of data) {
+ length += Buffer.byteLength(str, "utf8");
+ }
+ if (this.position + length > this.bufferSize) {
+ let newSize = this.bufferSize;
+ do {
+ newSize += this.bufferSize;
+ } while (this.position + length > newSize);
+ this.resize(newSize);
+ }
+ }
+
+ private compact() {
+ if (this.endOfLastRow > 0) {
+ this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
+ this.position = this.position - this.endOfLastRow;
+ this.endOfLastRow = 0;
+ }
+ }
+
+ private writeColumn(
+ name: string,
+ value: unknown,
+ writeValue: () => void,
+ valueType?: string
+ ) {
+ if (typeof name !== "string") {
+ throw new Error(`Column name must be a string, received ${typeof name}`);
+ }
+ if (valueType && typeof value !== valueType) {
+ throw new Error(
+ `Column value must be of type ${valueType}, received ${typeof value}`,
+ );
+ }
+ if (!this.hasTable) {
+ throw new Error("Column can be set only after table name is set");
+ }
+ this.checkCapacity([name], 2 + name.length);
+ this.write(this.hasColumns ? "," : " ");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ writeValue();
+ this.hasColumns = true;
+ }
+
+ private write(data: string) {
+ this.position += this.buffer.write(data, this.position);
+ if (this.position > this.bufferSize) {
+ // should never happen, if checkCapacity() is correctly used
+ throw new Error(
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
+ );
+ }
+ }
+
+ private writeEscaped(data: string, quoted = false) {
+ for (const ch of data) {
+ if (ch > "\\") {
+ this.write(ch);
+ continue;
+ }
+
+ switch (ch) {
+ case " ":
+ case ",":
+ case "=":
+ if (!quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\n":
+ case "\r":
+ this.write("\\");
+ this.write(ch);
+ break;
+ case '"':
+ if (quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\\":
+ this.write("\\\\");
+ break;
+ default:
+ this.write(ch);
+ break;
+ }
+ }
+ }
+}
+
+export { SenderBuffer, DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE };
diff --git a/src/options.ts b/src/options.ts
index abb5e1a..8fcb189 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -203,6 +203,10 @@ class SenderOptions {
}
static resolveDeprecated(options: SenderOptions & DeprecatedOptions, log: Logger) {
+ if (!options) {
+ return;
+ }
+
// deal with deprecated options
if (options.copy_buffer !== undefined) {
log("warn", `Option 'copy_buffer' is not supported anymore, please, remove it`);
diff --git a/src/sender.ts b/src/sender.ts
index fe90641..e174018 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -1,19 +1,12 @@
// @ts-check
-import { Buffer } from "node:buffer";
-
import { log, Logger } from "./logging";
-import { validateColumnName, validateTableName } from "./validation";
import { SenderOptions, ExtraOptions } from "./options";
import { SenderTransport, createTransport } from "./transport";
-import { isBoolean, isInteger, timestampToMicros, timestampToNanos } from "./utils";
+import { isBoolean, isInteger } from "./utils";
+import { SenderBuffer } from "./buffer";
const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
-const DEFAULT_MAX_NAME_LENGTH = 127;
-
-const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
-const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
-
/** @classdesc
* The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
* The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
@@ -61,11 +54,7 @@ const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
class Sender {
private readonly transport: SenderTransport;
- private bufferSize: number;
- private readonly maxBufferSize: number;
- private buffer: Buffer;
- private position: number;
- private endOfLastRow: number;
+ private buffer: SenderBuffer;
private readonly autoFlush: boolean;
private readonly autoFlushRows: number;
@@ -73,12 +62,6 @@ class Sender {
private lastFlushTime: number;
private pendingRowCount: number;
- private hasTable: boolean;
- private hasSymbols: boolean;
- private hasColumns: boolean;
-
- private readonly maxNameLength: number;
-
private readonly log: Logger;
/**
@@ -89,9 +72,9 @@ class Sender {
*/
constructor(options: SenderOptions) {
this.transport = createTransport(options);
+ this.buffer = new SenderBuffer(options);
this.log = typeof options.log === "function" ? options.log : log;
- SenderOptions.resolveDeprecated(options, this.log);
this.autoFlush = isBoolean(options.auto_flush) ? options.auto_flush : true;
this.autoFlushRows = isInteger(options.auto_flush_rows, 0)
@@ -101,18 +84,6 @@ class Sender {
? options.auto_flush_interval
: DEFAULT_AUTO_FLUSH_INTERVAL;
- this.maxNameLength = isInteger(options.max_name_len, 1)
- ? options.max_name_len
- : DEFAULT_MAX_NAME_LENGTH;
-
- this.maxBufferSize = isInteger(options.max_buf_size, 1)
- ? options.max_buf_size
- : DEFAULT_MAX_BUFFER_SIZE;
- this.resize(
- isInteger(options.init_buf_size, 1)
- ? options.init_buf_size
- : DEFAULT_BUFFER_SIZE,
- );
this.reset();
}
@@ -151,29 +122,6 @@ class Sender {
);
}
- /**
- * Extends the size of the sender's buffer.
- * Can be used to increase the size of buffer if overflown.
- * The buffer's content is copied into the new buffer.
- *
- * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
- */
- private resize(bufferSize: number) {
- if (bufferSize > this.maxBufferSize) {
- throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
- }
- this.bufferSize = bufferSize;
- // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
- // longer than the size of the buffer. It simply just writes whatever it can, and returns.
- // If we can write into the extra byte, that indicates buffer overflow.
- // See the check in our write() function.
- const newBuffer = Buffer.alloc(this.bufferSize + 1, 0, "utf8");
- if (this.buffer) {
- this.buffer.copy(newBuffer);
- }
- this.buffer = newBuffer;
- }
-
/**
* Resets the buffer, data added to the buffer will be lost.
* In other words it clears the buffer and sets the writing position to the beginning of the buffer.
@@ -181,10 +129,8 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
reset(): Sender {
- this.position = 0;
- this.lastFlushTime = Date.now();
- this.pendingRowCount = 0;
- this.startNewRow();
+ this.buffer.reset();
+ this.resetAutoFlush();
return this;
}
@@ -204,11 +150,14 @@ class Sender {
* @return {Promise} Resolves to true when there was data in the buffer to send, and it was sent successfully.
*/
async flush(): Promise {
- const dataToSend: Buffer = this.toBufferNew();
+ const dataToSend: Buffer = this.buffer.toBufferNew();
if (!dataToSend) {
return false; // Nothing to send
}
+ this.log("debug", `Flushing, number of flushed rows: ${this.pendingRowCount}`);
+ this.resetAutoFlush();
+
await this.transport.send(dataToSend);
}
@@ -220,32 +169,6 @@ class Sender {
return this.transport.close();
}
- /**
- * @ignore
- * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
- * The returned buffer is backed by the sender's buffer.
- * Used only in tests.
- */
- toBufferView(pos = this.endOfLastRow): Buffer {
- return pos > 0 ? this.buffer.subarray(0, pos) : null;
- }
-
- /**
- * @ignore
- * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
- * The returned buffer is a copy of the sender's buffer.
- * It also compacts the Sender's buffer.
- */
- toBufferNew(pos = this.endOfLastRow): Buffer | null {
- if (pos > 0) {
- const data = Buffer.allocUnsafe(pos);
- this.buffer.copy(data, 0, 0, pos);
- this.compact();
- return data;
- }
- return null;
- }
-
/**
* Write the table name into the buffer of the sender.
*
@@ -253,16 +176,7 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
table(table: string): Sender {
- if (typeof table !== "string") {
- throw new Error(`Table name must be a string, received ${typeof table}`);
- }
- if (this.hasTable) {
- throw new Error("Table name has already been set");
- }
- validateTableName(table, this.maxNameLength);
- this.checkCapacity([table]);
- this.writeEscaped(table);
- this.hasTable = true;
+ this.buffer.table(table);
return this;
}
@@ -270,24 +184,11 @@ class Sender {
* Write a symbol name and value into the buffer of the sender.
*
* @param {string} name - Symbol name.
- * @param {any} value - Symbol value, toString() will be called to extract the actual symbol value from the parameter.
+ * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
* @return {Sender} Returns with a reference to this sender.
*/
- symbol(name: string, value: T): Sender {
- if (typeof name !== "string") {
- throw new Error(`Symbol name must be a string, received ${typeof name}`);
- }
- if (!this.hasTable || this.hasColumns) {
- throw new Error("Symbol can be added only after table name is set and before any column added");
- }
- const valueStr = value.toString();
- this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length);
- this.write(",");
- validateColumnName(name, this.maxNameLength);
- this.writeEscaped(name);
- this.write("=");
- this.writeEscaped(valueStr);
- this.hasSymbols = true;
+ symbol(name: string, value: unknown): Sender {
+ this.buffer.symbol(name, value);
return this;
}
@@ -299,17 +200,7 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
stringColumn(name: string, value: string): Sender {
- this.writeColumn(
- name,
- value,
- () => {
- this.checkCapacity([value], 2 + value.length);
- this.write('"');
- this.writeEscaped(value, true);
- this.write('"');
- },
- "string",
- );
+ this.buffer.stringColumn(name, value);
return this;
}
@@ -321,15 +212,7 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
booleanColumn(name: string, value: boolean): Sender {
- this.writeColumn(
- name,
- value,
- () => {
- this.checkCapacity([], 1);
- this.write(value ? "t" : "f");
- },
- "boolean",
- );
+ this.buffer.booleanColumn(name, value);
return this;
}
@@ -341,16 +224,7 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
floatColumn(name: string, value: number): Sender {
- this.writeColumn(
- name,
- value,
- () => {
- const valueStr = value.toString();
- this.checkCapacity([valueStr], valueStr.length);
- this.write(valueStr);
- },
- "number",
- );
+ this.buffer.floatColumn(name, value);
return this;
}
@@ -362,15 +236,7 @@ class Sender {
* @return {Sender} Returns with a reference to this sender.
*/
intColumn(name: string, value: number): Sender {
- if (!Number.isInteger(value)) {
- throw new Error(`Value must be an integer, received ${value}`);
- }
- this.writeColumn(name, value, () => {
- const valueStr = value.toString();
- this.checkCapacity([valueStr], 1 + valueStr.length);
- this.write(valueStr);
- this.write("i");
- });
+ this.buffer.intColumn(name, value);
return this;
}
@@ -387,16 +253,7 @@ class Sender {
value: number | bigint,
unit: "ns" | "us" | "ms" = "us",
): Sender {
- if (typeof value !== "bigint" && !Number.isInteger(value)) {
- throw new Error(`Value must be an integer or BigInt, received ${value}`);
- }
- this.writeColumn(name, value, () => {
- const valueMicros = timestampToMicros(BigInt(value), unit);
- const valueStr = valueMicros.toString();
- this.checkCapacity([valueStr], 1 + valueStr.length);
- this.write(valueStr);
- this.write("t");
- });
+ this.buffer.timestampColumn(name, value, unit);
return this;
}
@@ -407,21 +264,10 @@ class Sender {
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
*/
async at(timestamp: number | bigint, unit: "ns" | "us" | "ms" = "us") {
- if (!this.hasSymbols && !this.hasColumns) {
- throw new Error("The row must have a symbol or column set before it is closed");
- }
- if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
- throw new Error(`Designated timestamp must be an integer or BigInt, received ${timestamp}`);
- }
- const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
- const timestampStr = timestampNanos.toString();
- this.checkCapacity([], 2 + timestampStr.length);
- this.write(" ");
- this.write(timestampStr);
- this.write("\n");
+ this.buffer.at(timestamp, unit);
this.pendingRowCount++;
- this.startNewRow();
- await this.automaticFlush();
+ this.log("debug", `Pending row count: ${this.pendingRowCount}`);
+ await this.tryFlush();
}
/**
@@ -429,132 +275,30 @@ class Sender {
* Designated timestamp will be populated by the server on this record.
*/
async atNow() {
- if (!this.hasSymbols && !this.hasColumns) {
- throw new Error("The row must have a symbol or column set before it is closed");
- }
- this.checkCapacity([], 1);
- this.write("\n");
+ this.buffer.atNow();
this.pendingRowCount++;
- this.startNewRow();
- await this.automaticFlush();
+ this.log("debug", `Pending row count: ${this.pendingRowCount}`);
+ await this.tryFlush();
}
- private startNewRow() {
- this.endOfLastRow = this.position;
- this.hasTable = false;
- this.hasSymbols = false;
- this.hasColumns = false;
+ private resetAutoFlush(): void {
+ this.lastFlushTime = Date.now();
+ this.pendingRowCount = 0;
+ this.log("debug", `Pending row count: ${this.pendingRowCount}`);
}
- private async automaticFlush() {
+ private async tryFlush() {
if (
- this.autoFlush &&
- this.pendingRowCount > 0 &&
- ((this.autoFlushRows > 0 &&
- this.pendingRowCount >= this.autoFlushRows) ||
- (this.autoFlushInterval > 0 &&
- Date.now() - this.lastFlushTime >= this.autoFlushInterval))
+ this.autoFlush
+ && this.pendingRowCount > 0
+ && (
+ (this.autoFlushRows > 0 && this.pendingRowCount >= this.autoFlushRows)
+ || (this.autoFlushInterval > 0 && Date.now() - this.lastFlushTime >= this.autoFlushInterval)
+ )
) {
await this.flush();
}
}
-
- private checkCapacity(data: string[], base = 0) {
- let length = base;
- for (const str of data) {
- length += Buffer.byteLength(str, "utf8");
- }
- if (this.position + length > this.bufferSize) {
- let newSize = this.bufferSize;
- do {
- newSize += this.bufferSize;
- } while (this.position + length > newSize);
- this.resize(newSize);
- }
- }
-
- private compact() {
- if (this.endOfLastRow > 0) {
- this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
- this.position = this.position - this.endOfLastRow;
- this.endOfLastRow = 0;
-
- this.lastFlushTime = Date.now();
- this.pendingRowCount = 0;
- }
- }
-
- private writeColumn(
- name: string,
- value: unknown,
- writeValue: () => void,
- valueType?: string
- ) {
- if (typeof name !== "string") {
- throw new Error(`Column name must be a string, received ${typeof name}`);
- }
- if (valueType && typeof value !== valueType) {
- throw new Error(
- `Column value must be of type ${valueType}, received ${typeof value}`,
- );
- }
- if (!this.hasTable) {
- throw new Error("Column can be set only after table name is set");
- }
- this.checkCapacity([name], 2 + name.length);
- this.write(this.hasColumns ? "," : " ");
- validateColumnName(name, this.maxNameLength);
- this.writeEscaped(name);
- this.write("=");
- writeValue();
- this.hasColumns = true;
- }
-
- private write(data: string) {
- this.position += this.buffer.write(data, this.position);
- if (this.position > this.bufferSize) {
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
- }
-
- private writeEscaped(data: string, quoted = false) {
- for (const ch of data) {
- if (ch > "\\") {
- this.write(ch);
- continue;
- }
-
- switch (ch) {
- case " ":
- case ",":
- case "=":
- if (!quoted) {
- this.write("\\");
- }
- this.write(ch);
- break;
- case "\n":
- case "\r":
- this.write("\\");
- this.write(ch);
- break;
- case '"':
- if (quoted) {
- this.write("\\");
- }
- this.write(ch);
- break;
- case "\\":
- this.write("\\\\");
- break;
- default:
- this.write(ch);
- break;
- }
- }
- }
}
-export { Sender, DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE };
+export { Sender };
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 9e7c745..5d36db7 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -60,7 +60,7 @@ describe("Client interop test suite", function () {
}
if (!errorMessage) {
- const actualLine = sender.toBufferView().toString();
+ const actualLine = bufferContent(sender);
if (testCase.result.status === "SUCCESS") {
if (testCase.result.line) {
@@ -150,7 +150,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.atNow();
}
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716180\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2800\\"}]",boolCol=t\n' +
'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716181\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2801\\"}]",boolCol=t\n' +
'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716182\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2802\\"}]",boolCol=t\n' +
@@ -170,7 +170,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -187,7 +187,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000, "ns")
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000t\n",
);
await sender.close();
@@ -204,7 +204,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000, "us")
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -221,7 +221,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000, "ms")
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -238,7 +238,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000n)
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -255,7 +255,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000000n, "ns")
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -272,7 +272,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000n, "us")
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -289,7 +289,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000n, "ms")
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n",
);
await sender.close();
@@ -325,7 +325,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.at(1658484769000000, "us");
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
);
await sender.close();
@@ -342,7 +342,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.at(1658484769000, "ms");
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
);
await sender.close();
@@ -359,7 +359,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.at(1658484769000000n);
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
);
await sender.close();
@@ -376,7 +376,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.at(1658484769000000123n, "ns");
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000123\n",
);
await sender.close();
@@ -393,7 +393,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.at(1658484769000000n, "us");
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
);
await sender.close();
@@ -410,7 +410,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", true)
.timestampColumn("timestampCol", 1658484765000000)
.at(1658484769000n, "ms");
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t 1658484769000000000\n",
);
await sender.close();
@@ -582,8 +582,10 @@ describe("Sender message builder test suite (anything not covered in client inte
host: "host",
init_buf_size: 1024,
});
- expect(sender.toBufferView()).toBe(null);
- expect(sender.toBufferNew()).toBe(null);
+ // @ts-expect-error - Accessing private field
+ expect(sender.buffer.toBufferView()).toBe(null);
+ // @ts-expect-error - Accessing private field
+ expect(sender.buffer.toBufferNew()).toBe(null);
await sender.close();
});
@@ -598,12 +600,13 @@ describe("Sender message builder test suite (anything not covered in client inte
sender.table("tableName").symbol("name", "value2");
// copy of the sender's buffer contains the finished row
- expect(sender.toBufferNew().toString()).toBe(
+ // @ts-expect-error - Accessing private field
+ expect(sender.buffer.toBufferNew().toString()).toBe(
"tableName,name=value 1234567890\n",
);
// the sender's buffer is compacted, and contains only the unfinished row
// @ts-expect-error - Accessing private field
- expect(sender.toBufferView(sender.position).toString()).toBe(
+ expect(sender.buffer.toBufferView(bufferPosition(sender)).toString()).toBe(
"tableName,name=value2",
);
await sender.close();
@@ -691,39 +694,29 @@ describe("Sender message builder test suite (anything not covered in client inte
host: "host",
init_buf_size: 8,
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(8);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe(0);
+ expect(bufferSize(sender)).toBe(8);
+ expect(bufferPosition(sender)).toBe(0);
sender.table("tableName");
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(16);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe("tableName".length);
+ expect(bufferSize(sender)).toBe(24);
+ expect(bufferPosition(sender)).toBe("tableName".length);
sender.intColumn("intField", 123);
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(32);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe("tableName intField=123i".length);
+ expect(bufferSize(sender)).toBe(48);
+ expect(bufferPosition(sender)).toBe("tableName intField=123i".length);
await sender.atNow();
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(32);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe("tableName intField=123i\n".length);
- expect(sender.toBufferView().toString()).toBe("tableName intField=123i\n");
+ expect(bufferSize(sender)).toBe(48);
+ expect(bufferPosition(sender)).toBe("tableName intField=123i\n".length);
+ expect(bufferContent(sender)).toBe("tableName intField=123i\n");
await sender
.table("table2")
.intColumn("intField", 125)
.stringColumn("strField", "test")
.atNow();
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(64);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe(
+ expect(bufferSize(sender)).toBe(96);
+ expect(bufferPosition(sender)).toBe(
'tableName intField=123i\ntable2 intField=125i,strField="test"\n'.length,
);
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
'tableName intField=123i\ntable2 intField=125i,strField="test"\n',
);
await sender.close();
@@ -731,28 +724,20 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if tries to extend the size of the buffer above max buffer size", async function () {
const sender = Sender.fromConfig(
- "tcp::addr=host;init_buf_size=8;max_buf_size=48;",
+ "tcp::addr=host;init_buf_size=8;max_buf_size=64;",
);
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(8);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe(0);
+ expect(bufferSize(sender)).toBe(8);
+ expect(bufferPosition(sender)).toBe(0);
sender.table("tableName");
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(16);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe("tableName".length);
+ expect(bufferSize(sender)).toBe(24);
+ expect(bufferPosition(sender)).toBe("tableName".length);
sender.intColumn("intField", 123);
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(32);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe("tableName intField=123i".length);
+ expect(bufferSize(sender)).toBe(48);
+ expect(bufferPosition(sender)).toBe("tableName intField=123i".length);
await sender.atNow();
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(32);
- // @ts-expect-error - Accessing private field
- expect(sender.position).toBe("tableName intField=123i\n".length);
- expect(sender.toBufferView().toString()).toBe("tableName intField=123i\n");
+ expect(bufferSize(sender)).toBe(48);
+ expect(bufferPosition(sender)).toBe("tableName intField=123i\n".length);
+ expect(bufferContent(sender)).toBe("tableName intField=123i\n");
try {
await sender
@@ -762,7 +747,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.atNow();
} catch (err) {
expect(err.message).toBe(
- "Max buffer size is 48 bytes, requested buffer size: 64",
+ "Max buffer size is 64 bytes, requested buffer size: 96",
);
}
await sender.close();
@@ -784,7 +769,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.booleanColumn("boolCol", false)
.timestampColumn("timestampCol", 1658484766000000)
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n" +
"tableName boolCol=f,timestampCol=1658484766000000t\n",
);
@@ -795,9 +780,24 @@ describe("Sender message builder test suite (anything not covered in client inte
.floatColumn("floatCol", 1234567890)
.timestampColumn("timestampCol", 1658484767000000)
.atNow();
- expect(sender.toBufferView().toString()).toBe(
+ expect(bufferContent(sender)).toBe(
"tableName floatCol=1234567890,timestampCol=1658484767000000t\n",
);
await sender.close();
});
});
+
+function bufferContent(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return sender.buffer.toBufferView().toString();
+}
+
+function bufferSize(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return sender.buffer.bufferSize;
+}
+
+function bufferPosition(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return sender.buffer.position;
+}
diff --git a/test/sender.config.test.ts b/test/sender.config.test.ts
index 6676d65..7c70a7c 100644
--- a/test/sender.config.test.ts
+++ b/test/sender.config.test.ts
@@ -1,7 +1,8 @@
// @ts-check
import { describe, it, expect } from "vitest";
-import { Sender, DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE } from "../src/sender";
+import { Sender } from "../src";
+import { DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE } from "../src/buffer";
import { log } from "../src/logging";
describe("Sender configuration options suite", function () {
@@ -55,7 +56,7 @@ describe("Sender configuration options suite", function () {
describe("Sender options test suite", function () {
it("fails if no options defined", async function () {
await expect(async () =>
- // @ts-expect-error - Testing invalid options
+ // @ts-expect-error - Testing invalid options
await new Sender().close()
).rejects.toThrow("The 'protocol' option is mandatory");
});
@@ -97,8 +98,7 @@ describe("Sender options test suite", function () {
protocol: "http",
host: "host",
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ expect(bufferSize(sender)).toBe(DEFAULT_BUFFER_SIZE);
await sender.close();
});
@@ -108,8 +108,7 @@ describe("Sender options test suite", function () {
host: "host",
init_buf_size: 1024,
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(1024);
+ expect(bufferSize(sender)).toBe(1024);
await sender.close();
});
@@ -119,8 +118,7 @@ describe("Sender options test suite", function () {
host: "host",
init_buf_size: null,
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ expect(bufferSize(sender)).toBe(DEFAULT_BUFFER_SIZE);
await sender.close();
});
@@ -130,8 +128,7 @@ describe("Sender options test suite", function () {
host: "host",
init_buf_size: undefined,
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ expect(bufferSize(sender)).toBe(DEFAULT_BUFFER_SIZE);
await sender.close();
});
@@ -142,15 +139,16 @@ describe("Sender options test suite", function () {
// @ts-expect-error - Testing invalid options
init_buf_size: "1024",
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(DEFAULT_BUFFER_SIZE);
+ expect(bufferSize(sender)).toBe(DEFAULT_BUFFER_SIZE);
await sender.close();
});
it("sets the requested buffer size if 'bufferSize' is set, but warns that it is deprecated", async function () {
const log = (level: "error" | "warn" | "info" | "debug", message: string | Error) => {
- expect(level).toBe("warn");
- expect(message).toMatch("Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'");
+ if (level !== "debug") {
+ expect(level).toBe("warn");
+ expect(message).toMatch("Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'");
+ }
};
const sender = new Sender({
protocol: "http",
@@ -159,15 +157,16 @@ describe("Sender options test suite", function () {
bufferSize: 2048,
log: log,
});
- // @ts-expect-error - Accessing private field
- expect(sender.bufferSize).toBe(2048);
+ expect(bufferSize(sender)).toBe(2048);
await sender.close();
});
it("warns about deprecated option 'copy_buffer'", async function () {
const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("warn");
- expect(message).toMatch("Option 'copy_buffer' is not supported anymore, please, remove it");
+ if (level !== "debug") {
+ expect(level).toBe("warn");
+ expect(message).toMatch("Option 'copy_buffer' is not supported anymore, please, remove it");
+ }
};
const sender = new Sender({
protocol: "http",
@@ -181,8 +180,10 @@ describe("Sender options test suite", function () {
it("warns about deprecated option 'copyBuffer'", async function () {
const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("warn");
- expect(message).toMatch("Option 'copyBuffer' is not supported anymore, please, remove it");
+ if (level !== "debug") {
+ expect(level).toBe("warn");
+ expect(message).toMatch("Option 'copyBuffer' is not supported anymore, please, remove it");
+ }
};
const sender = new Sender({
protocol: "http",
@@ -196,8 +197,7 @@ describe("Sender options test suite", function () {
it("sets default max buffer size if max_buf_size is not set", async function () {
const sender = new Sender({ protocol: "http", host: "host" });
- // @ts-expect-error - Accessing private field
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ expect(maxBufferSize(sender)).toBe(DEFAULT_MAX_BUFFER_SIZE);
await sender.close();
});
@@ -207,8 +207,7 @@ describe("Sender options test suite", function () {
host: "host",
max_buf_size: 131072,
});
- // @ts-expect-error - Accessing private field
- expect(sender.maxBufferSize).toBe(131072);
+ expect(maxBufferSize(sender)).toBe(131072);
await sender.close();
});
@@ -229,8 +228,7 @@ describe("Sender options test suite", function () {
host: "host",
max_buf_size: null,
});
- // @ts-expect-error - Accessing private field
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ expect(maxBufferSize(sender)).toBe(DEFAULT_MAX_BUFFER_SIZE);
await sender.close();
});
@@ -240,8 +238,7 @@ describe("Sender options test suite", function () {
host: "host",
max_buf_size: undefined,
});
- // @ts-expect-error - Accessing private field
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ expect(maxBufferSize(sender)).toBe(DEFAULT_MAX_BUFFER_SIZE);
await sender.close();
});
@@ -252,15 +249,13 @@ describe("Sender options test suite", function () {
// @ts-expect-error - Testing invalid value
max_buf_size: "1024",
});
- // @ts-expect-error - Accessing private field
- expect(sender.maxBufferSize).toBe(DEFAULT_MAX_BUFFER_SIZE);
+ expect(maxBufferSize(sender)).toBe(DEFAULT_MAX_BUFFER_SIZE);
await sender.close();
});
it("uses default logger if log function is not set", async function () {
const sender = new Sender({ protocol: "http", host: "host" });
- // @ts-expect-error - Accessing private field
- expect(sender.log).toBe(log);
+ expect(logger(sender)).toBe(log);
await sender.close();
});
@@ -271,15 +266,13 @@ describe("Sender options test suite", function () {
host: "host",
log: testFunc,
});
- // @ts-expect-error - Accessing private field
- expect(sender.log).toBe(testFunc);
+ expect(logger(sender)).toBe(testFunc);
await sender.close();
});
it("uses default logger if log is set to null", async function () {
const sender = new Sender({ protocol: "http", host: "host", log: null });
- // @ts-expect-error - Accessing private field
- expect(sender.log).toBe(log);
+ expect(logger(sender)).toBe(log);
await sender.close();
});
@@ -289,16 +282,14 @@ describe("Sender options test suite", function () {
host: "host",
log: undefined,
});
- // @ts-expect-error - Accessing private field
- expect(sender.log).toBe(log);
+ expect(logger(sender)).toBe(log);
await sender.close();
});
it("uses default logger if log is not a function", async function () {
// @ts-expect-error - Testing invalid options
const sender = new Sender({ protocol: "http", host: "host", log: "" });
- // @ts-expect-error - Accessing private field
- expect(sender.log).toBe(log);
+ expect(logger(sender)).toBe(log);
await sender.close();
});
});
@@ -400,3 +391,18 @@ describe("Sender auth config checks suite", function () {
);
});
});
+
+function bufferSize(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return sender.buffer.bufferSize;
+}
+
+function maxBufferSize(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return sender.buffer.maxBufferSize;
+}
+
+function logger(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return sender.log;
+}
diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts
index 3b32fcd..39edd9d 100644
--- a/test/sender.transport.test.ts
+++ b/test/sender.transport.test.ts
@@ -545,8 +545,10 @@ describe("Sender TCP suite", function () {
/^Connection to .*1:9088 is closed$/,
];
const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
- expect(level).toBe("info");
- expect(message).toMatch(expectedMessages.shift());
+ if (level !== "debug") {
+ expect(level).toBe("info");
+ expect(message).toMatch(expectedMessages.shift());
+ }
};
const proxy = await createProxy();
const sender = new Sender({
From 7f4d4548f3f3c631273f4e96ace226c2c6c3f53c Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 17 Jul 2025 03:17:55 +0100
Subject: [PATCH 05/38] TimestampUnit type
---
src/buffer/index.ts | 10 +++-------
src/sender.ts | 10 +++-------
src/utils.ts | 12 +++++++-----
3 files changed, 13 insertions(+), 19 deletions(-)
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index eb8f0a0..3b2072a 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -4,7 +4,7 @@ import { Buffer } from "node:buffer";
import { log, Logger } from "../logging";
import { validateColumnName, validateTableName } from "../validation";
import { SenderOptions } from "../options";
-import { isInteger, timestampToMicros, timestampToNanos } from "../utils";
+import { isInteger, timestampToMicros, timestampToNanos, TimestampUnit } from "../utils";
const DEFAULT_MAX_NAME_LENGTH = 127;
@@ -262,11 +262,7 @@ class SenderBuffer {
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
* @return {Sender} Returns with a reference to this sender.
*/
- timestampColumn(
- name: string,
- value: number | bigint,
- unit: "ns" | "us" | "ms" = "us",
- ): SenderBuffer {
+ timestampColumn(name: string, value: number | bigint, unit: TimestampUnit = "us"): SenderBuffer {
if (typeof value !== "bigint" && !Number.isInteger(value)) {
throw new Error(`Value must be an integer or BigInt, received ${value}`);
}
@@ -286,7 +282,7 @@ class SenderBuffer {
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
*/
- at(timestamp: number | bigint, unit: "ns" | "us" | "ms" = "us") {
+ at(timestamp: number | bigint, unit: TimestampUnit = "us") {
if (!this.hasSymbols && !this.hasColumns) {
throw new Error("The row must have a symbol or column set before it is closed");
}
diff --git a/src/sender.ts b/src/sender.ts
index e174018..b94f969 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -2,7 +2,7 @@
import { log, Logger } from "./logging";
import { SenderOptions, ExtraOptions } from "./options";
import { SenderTransport, createTransport } from "./transport";
-import { isBoolean, isInteger } from "./utils";
+import { isBoolean, isInteger, TimestampUnit } from "./utils";
import { SenderBuffer } from "./buffer";
const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
@@ -248,11 +248,7 @@ class Sender {
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
* @return {Sender} Returns with a reference to this sender.
*/
- timestampColumn(
- name: string,
- value: number | bigint,
- unit: "ns" | "us" | "ms" = "us",
- ): Sender {
+ timestampColumn(name: string, value: number | bigint, unit: TimestampUnit = "us"): Sender {
this.buffer.timestampColumn(name, value, unit);
return this;
}
@@ -263,7 +259,7 @@ class Sender {
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
*/
- async at(timestamp: number | bigint, unit: "ns" | "us" | "ms" = "us") {
+ async at(timestamp: number | bigint, unit: TimestampUnit = "us") {
this.buffer.at(timestamp, unit);
this.pendingRowCount++;
this.log("debug", `Pending row count: ${this.pendingRowCount}`);
diff --git a/src/utils.ts b/src/utils.ts
index dd82db6..1962242 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,5 @@
+type TimestampUnit = "ns" | "us" | "ms";
+
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
@@ -8,7 +10,7 @@ function isInteger(value: unknown, lowerBound: number): value is number {
);
}
-function timestampToMicros(timestamp: bigint, unit: "ns" | "us" | "ms") {
+function timestampToMicros(timestamp: bigint, unit: TimestampUnit) {
switch (unit) {
case "ns":
return timestamp / 1000n;
@@ -17,11 +19,11 @@ function timestampToMicros(timestamp: bigint, unit: "ns" | "us" | "ms") {
case "ms":
return timestamp * 1000n;
default:
- throw new Error("Unknown timestamp unit: " + unit);
+ throw new Error(`Unknown timestamp unit: ${unit}`);
}
}
-function timestampToNanos(timestamp: bigint, unit: "ns" | "us" | "ms") {
+function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
switch (unit) {
case "ns":
return timestamp;
@@ -30,8 +32,8 @@ function timestampToNanos(timestamp: bigint, unit: "ns" | "us" | "ms") {
case "ms":
return timestamp * 1000_000n;
default:
- throw new Error("Unknown timestamp unit: " + unit);
+ throw new Error(`Unknown timestamp unit: ${unit}`);
}
}
-export { isBoolean, isInteger, timestampToMicros, timestampToNanos };
+export { isBoolean, isInteger, timestampToMicros, timestampToNanos, TimestampUnit };
From f54ad6527ddd8ee3fb2146316498a4934a66bd7b Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 23 Jul 2025 23:25:03 +0100
Subject: [PATCH 06/38] code formatting
---
package.json | 1 +
src/buffer/index.ts | 61 ++++++---
src/logging.ts | 9 +-
src/options.ts | 49 +++++--
src/sender.ts | 27 ++--
src/transport/http/base.ts | 19 +--
src/transport/http/legacy.ts | 81 +++++++----
src/transport/http/undici.ts | 57 +++++---
src/transport/index.ts | 6 +-
src/transport/tcp.ts | 64 +++++----
src/utils.ts | 10 +-
test/logging.test.ts | 18 ++-
test/sender.buffer.test.ts | 69 +++++-----
test/sender.config.test.ts | 234 ++++++++++++++++++--------------
test/sender.integration.test.ts | 38 ++++--
test/sender.transport.test.ts | 35 ++---
test/util/mockhttp.ts | 75 +++++-----
test/util/mockproxy.ts | 4 +-
test/util/proxy.ts | 9 +-
test/util/proxyfunctions.ts | 16 ++-
20 files changed, 548 insertions(+), 334 deletions(-)
diff --git a/package.json b/package.json
index 4c10ee7..3b6b129 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"build": "bunchee",
"eslint": "eslint src/**",
"typecheck": "tsc --noEmit",
+ "format": "prettier --write '{src,test}/**/*.{ts,js,json}'",
"docs": "pnpm run build && jsdoc ./dist/cjs/index.js README.md -d docs",
"preview:docs": "serve docs"
},
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index 3b2072a..383f23f 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -4,7 +4,12 @@ import { Buffer } from "node:buffer";
import { log, Logger } from "../logging";
import { validateColumnName, validateTableName } from "../validation";
import { SenderOptions } from "../options";
-import { isInteger, timestampToMicros, timestampToNanos, TimestampUnit } from "../utils";
+import {
+ isInteger,
+ timestampToMicros,
+ timestampToNanos,
+ TimestampUnit,
+} from "../utils";
const DEFAULT_MAX_NAME_LENGTH = 127;
@@ -42,15 +47,17 @@ class SenderBuffer {
this.log = options && typeof options.log === "function" ? options.log : log;
SenderOptions.resolveDeprecated(options, this.log);
- this.maxNameLength = options && isInteger(options.max_name_len, 1)
- ? options.max_name_len
- : DEFAULT_MAX_NAME_LENGTH;
+ this.maxNameLength =
+ options && isInteger(options.max_name_len, 1)
+ ? options.max_name_len
+ : DEFAULT_MAX_NAME_LENGTH;
- this.maxBufferSize = options && isInteger(options.max_buf_size, 1)
- ? options.max_buf_size
- : DEFAULT_MAX_BUFFER_SIZE;
+ this.maxBufferSize =
+ options && isInteger(options.max_buf_size, 1)
+ ? options.max_buf_size
+ : DEFAULT_MAX_BUFFER_SIZE;
this.resize(
- options && isInteger(options.init_buf_size, 1)
+ options && isInteger(options.init_buf_size, 1)
? options.init_buf_size
: DEFAULT_BUFFER_SIZE,
);
@@ -67,7 +74,9 @@ class SenderBuffer {
*/
private resize(bufferSize: number) {
if (bufferSize > this.maxBufferSize) {
- throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
+ throw new Error(
+ `Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`,
+ );
}
this.bufferSize = bufferSize;
// Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
@@ -158,7 +167,9 @@ class SenderBuffer {
throw new Error(`Symbol name must be a string, received ${typeof name}`);
}
if (!this.hasTable || this.hasColumns) {
- throw new Error("Symbol can be added only after table name is set and before any column added");
+ throw new Error(
+ "Symbol can be added only after table name is set and before any column added",
+ );
}
const valueStr = value.toString();
this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length);
@@ -262,7 +273,11 @@ class SenderBuffer {
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
* @return {Sender} Returns with a reference to this sender.
*/
- timestampColumn(name: string, value: number | bigint, unit: TimestampUnit = "us"): SenderBuffer {
+ timestampColumn(
+ name: string,
+ value: number | bigint,
+ unit: TimestampUnit = "us",
+ ): SenderBuffer {
if (typeof value !== "bigint" && !Number.isInteger(value)) {
throw new Error(`Value must be an integer or BigInt, received ${value}`);
}
@@ -284,10 +299,14 @@ class SenderBuffer {
*/
at(timestamp: number | bigint, unit: TimestampUnit = "us") {
if (!this.hasSymbols && !this.hasColumns) {
- throw new Error("The row must have a symbol or column set before it is closed");
+ throw new Error(
+ "The row must have a symbol or column set before it is closed",
+ );
}
if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
- throw new Error(`Designated timestamp must be an integer or BigInt, received ${timestamp}`);
+ throw new Error(
+ `Designated timestamp must be an integer or BigInt, received ${timestamp}`,
+ );
}
const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
const timestampStr = timestampNanos.toString();
@@ -304,7 +323,9 @@ class SenderBuffer {
*/
atNow() {
if (!this.hasSymbols && !this.hasColumns) {
- throw new Error("The row must have a symbol or column set before it is closed");
+ throw new Error(
+ "The row must have a symbol or column set before it is closed",
+ );
}
this.checkCapacity([], 1);
this.write("\n");
@@ -334,17 +355,17 @@ class SenderBuffer {
}
private writeColumn(
- name: string,
- value: unknown,
- writeValue: () => void,
- valueType?: string
+ name: string,
+ value: unknown,
+ writeValue: () => void,
+ valueType?: string,
) {
if (typeof name !== "string") {
throw new Error(`Column name must be a string, received ${typeof name}`);
}
if (valueType && typeof value !== valueType) {
throw new Error(
- `Column value must be of type ${valueType}, received ${typeof value}`,
+ `Column value must be of type ${valueType}, received ${typeof value}`,
);
}
if (!this.hasTable) {
@@ -364,7 +385,7 @@ class SenderBuffer {
if (this.position > this.bufferSize) {
// should never happen, if checkCapacity() is correctly used
throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
);
}
}
diff --git a/src/logging.ts b/src/logging.ts
index 48098b2..751629e 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -8,8 +8,8 @@ const LOG_LEVELS = {
const DEFAULT_CRITICALITY = LOG_LEVELS.info.criticality;
type Logger = (
- level: "error" | "warn" | "info" | "debug",
- message: string | Error,
+ level: "error" | "warn" | "info" | "debug",
+ message: string | Error,
) => void;
/**
@@ -20,7 +20,10 @@ type Logger = (
* @param {'error'|'warn'|'info'|'debug'} level - The log level of the message.
* @param {string | Error} message - The log message.
*/
-function log(level: "error" | "warn" | "info" | "debug", message: string | Error) {
+function log(
+ level: "error" | "warn" | "info" | "debug",
+ message: string | Error,
+) {
const logLevel = LOG_LEVELS[level];
if (!logLevel) {
throw new Error(`Invalid log level: '${level}'`);
diff --git a/src/options.ts b/src/options.ts
index 8fcb189..f7ec648 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -20,7 +20,7 @@ const UNSAFE_OFF = "unsafe_off";
type ExtraOptions = {
log?: Logger;
agent?: Agent | http.Agent | https.Agent;
-}
+};
type DeprecatedOptions = {
/** @deprecated */
@@ -190,11 +190,11 @@ class SenderOptions {
this.log = extraOptions.log;
if (
- extraOptions.agent
- && !(extraOptions.agent instanceof Agent)
- && !(extraOptions.agent instanceof http.Agent)
- // @ts-expect-error - Not clear what the problem is, the two lines above have no issues
- && !(extraOptions.agent instanceof https.Agent)
+ extraOptions.agent &&
+ !(extraOptions.agent instanceof Agent) &&
+ !(extraOptions.agent instanceof http.Agent) &&
+ // @ts-expect-error - Not clear what the problem is, the two lines above have no issues
+ !(extraOptions.agent instanceof https.Agent)
) {
throw new Error("Invalid HTTP agent");
}
@@ -202,22 +202,34 @@ class SenderOptions {
}
}
- static resolveDeprecated(options: SenderOptions & DeprecatedOptions, log: Logger) {
+ static resolveDeprecated(
+ options: SenderOptions & DeprecatedOptions,
+ log: Logger,
+ ) {
if (!options) {
return;
}
// deal with deprecated options
if (options.copy_buffer !== undefined) {
- log("warn", `Option 'copy_buffer' is not supported anymore, please, remove it`);
+ log(
+ "warn",
+ `Option 'copy_buffer' is not supported anymore, please, remove it`,
+ );
options.copy_buffer = undefined;
}
if (options.copyBuffer !== undefined) {
- log("warn", `Option 'copyBuffer' is not supported anymore, please, remove it`);
+ log(
+ "warn",
+ `Option 'copyBuffer' is not supported anymore, please, remove it`,
+ );
options.copyBuffer = undefined;
}
if (options.bufferSize !== undefined) {
- log("warn", `Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'`);
+ log(
+ "warn",
+ `Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'`,
+ );
options.init_buf_size = options.bufferSize;
options.bufferSize = undefined;
}
@@ -235,7 +247,10 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
*/
- static fromConfig(configurationString: string, extraOptions?: ExtraOptions): SenderOptions {
+ static fromConfig(
+ configurationString: string,
+ extraOptions?: ExtraOptions,
+ ): SenderOptions {
return new SenderOptions(configurationString, extraOptions);
}
@@ -255,7 +270,10 @@ class SenderOptions {
}
}
-function parseConfigurationString(options: SenderOptions, configString: string) {
+function parseConfigurationString(
+ options: SenderOptions,
+ configString: string,
+) {
if (!configString) {
throw new Error("Configuration string is missing or empty");
}
@@ -278,7 +296,10 @@ function parseSettings(
) {
let index = configString.indexOf(";", position);
while (index > -1) {
- if (index + 1 < configString.length && configString.charAt(index + 1) === ";") {
+ if (
+ index + 1 < configString.length &&
+ configString.charAt(index + 1) === ";"
+ ) {
index = configString.indexOf(";", index + 2);
continue;
}
@@ -436,7 +457,7 @@ function parseTlsOptions(options: SenderOptions) {
if (options.tls_roots || options.tls_roots_password) {
throw new Error(
"'tls_roots' and 'tls_roots_password' options are not supported, please, " +
- "use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
+ "use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
}
}
diff --git a/src/sender.ts b/src/sender.ts
index b94f969..62db289 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -99,7 +99,10 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the provided configuration string.
*/
- static fromConfig(configurationString: string, extraOptions?: ExtraOptions): Sender {
+ static fromConfig(
+ configurationString: string,
+ extraOptions?: ExtraOptions,
+ ): Sender {
return new Sender(
SenderOptions.fromConfig(configurationString, extraOptions),
);
@@ -155,7 +158,10 @@ class Sender {
return false; // Nothing to send
}
- this.log("debug", `Flushing, number of flushed rows: ${this.pendingRowCount}`);
+ this.log(
+ "debug",
+ `Flushing, number of flushed rows: ${this.pendingRowCount}`,
+ );
this.resetAutoFlush();
await this.transport.send(dataToSend);
@@ -248,7 +254,11 @@ class Sender {
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
* @return {Sender} Returns with a reference to this sender.
*/
- timestampColumn(name: string, value: number | bigint, unit: TimestampUnit = "us"): Sender {
+ timestampColumn(
+ name: string,
+ value: number | bigint,
+ unit: TimestampUnit = "us",
+ ): Sender {
this.buffer.timestampColumn(name, value, unit);
return this;
}
@@ -285,12 +295,11 @@ class Sender {
private async tryFlush() {
if (
- this.autoFlush
- && this.pendingRowCount > 0
- && (
- (this.autoFlushRows > 0 && this.pendingRowCount >= this.autoFlushRows)
- || (this.autoFlushInterval > 0 && Date.now() - this.lastFlushTime >= this.autoFlushInterval)
- )
+ this.autoFlush &&
+ this.pendingRowCount > 0 &&
+ ((this.autoFlushRows > 0 && this.pendingRowCount >= this.autoFlushRows) ||
+ (this.autoFlushInterval > 0 &&
+ Date.now() - this.lastFlushTime >= this.autoFlushInterval))
) {
await this.flush();
}
diff --git a/src/transport/http/base.ts b/src/transport/http/base.ts
index 0d42b61..cdaf5f3 100644
--- a/src/transport/http/base.ts
+++ b/src/transport/http/base.ts
@@ -76,14 +76,14 @@ abstract class HttpTransportBase implements SenderTransport {
this.port = options.port;
this.requestMinThroughput = isInteger(options.request_min_throughput, 0)
- ? options.request_min_throughput
- : DEFAULT_REQUEST_MIN_THROUGHPUT;
+ ? options.request_min_throughput
+ : DEFAULT_REQUEST_MIN_THROUGHPUT;
this.requestTimeout = isInteger(options.request_timeout, 1)
- ? options.request_timeout
- : DEFAULT_REQUEST_TIMEOUT;
+ ? options.request_timeout
+ : DEFAULT_REQUEST_TIMEOUT;
this.retryTimeout = isInteger(options.retry_timeout, 0)
- ? options.retry_timeout
- : DEFAULT_RETRY_TIMEOUT;
+ ? options.retry_timeout
+ : DEFAULT_RETRY_TIMEOUT;
switch (options.protocol) {
case HTTP:
@@ -93,7 +93,9 @@ abstract class HttpTransportBase implements SenderTransport {
this.secure = true;
break;
default:
- throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
+ throw new Error(
+ "The 'protocol' has to be 'http' or 'https' for the HTTP transport",
+ );
}
}
@@ -101,8 +103,7 @@ abstract class HttpTransportBase implements SenderTransport {
throw new Error("'connect()' is not required for HTTP transport");
}
- async close(): Promise {
- }
+ async close(): Promise {}
getDefaultAutoFlushRows(): number {
return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
diff --git a/src/transport/http/legacy.ts b/src/transport/http/legacy.ts
index d62852f..e51a88f 100644
--- a/src/transport/http/legacy.ts
+++ b/src/transport/http/legacy.ts
@@ -4,7 +4,11 @@ import https from "https";
import { Buffer } from "node:buffer";
import { SenderOptions, HTTP, HTTPS } from "../../options";
-import { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT } from "./base";
+import {
+ HttpTransportBase,
+ RETRIABLE_STATUS_CODES,
+ HTTP_NO_CONTENT,
+} from "./base";
// default options for HTTP agent
// - persistent connections with 1 minute idle timeout, server side has 5 minutes set by default
@@ -12,8 +16,8 @@ import { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT } from "./ba
const DEFAULT_HTTP_AGENT_CONFIG = {
maxSockets: 256,
keepAlive: true,
- timeout: 60000 // 1 min
-}
+ timeout: 60000, // 1 min
+};
/** @classdesc
* The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
@@ -37,60 +41,81 @@ class HttpTransport extends HttpTransportBase {
switch (options.protocol) {
case HTTP:
- this.agent = options.agent instanceof http.Agent ? options.agent : HttpTransport.getDefaultHttpAgent();
+ this.agent =
+ options.agent instanceof http.Agent
+ ? options.agent
+ : HttpTransport.getDefaultHttpAgent();
break;
case HTTPS:
- this.agent = options.agent instanceof https.Agent ? options.agent : HttpTransport.getDefaultHttpsAgent();
+ this.agent =
+ options.agent instanceof https.Agent
+ ? options.agent
+ : HttpTransport.getDefaultHttpsAgent();
break;
default:
- throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
+ throw new Error(
+ "The 'protocol' has to be 'http' or 'https' for the HTTP transport",
+ );
}
}
send(data: Buffer, retryBegin = -1, retryInterval = -1): Promise {
const request = this.secure ? https.request : http.request;
- const timeoutMillis = (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
+ const timeoutMillis =
+ (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
const options = this.createRequestOptions(timeoutMillis);
return new Promise((resolve, reject) => {
let statusCode = -1;
- const req = request(options, response => {
+ const req = request(options, (response) => {
statusCode = response.statusCode;
const body = [];
response
- .on("data", chunk => {
- body.push(chunk);
- })
- .on("error", err => {
- this.log("error", `resp err=${err}`);
- });
+ .on("data", (chunk) => {
+ body.push(chunk);
+ })
+ .on("error", (err) => {
+ this.log("error", `resp err=${err}`);
+ });
if (statusCode === HTTP_NO_CONTENT) {
response.on("end", () => {
if (body.length > 0) {
- this.log("warn", `Unexpected message from server: ${Buffer.concat(body)}`);
+ this.log(
+ "warn",
+ `Unexpected message from server: ${Buffer.concat(body)}`,
+ );
}
resolve(true);
});
} else {
- req.destroy(new Error(`HTTP request failed, statusCode=${statusCode}, error=${Buffer.concat(body)}`));
+ req.destroy(
+ new Error(
+ `HTTP request failed, statusCode=${statusCode}, error=${Buffer.concat(body)}`,
+ ),
+ );
}
});
if (this.token) {
req.setHeader("Authorization", `Bearer ${this.token}`);
} else if (this.username && this.password) {
- req.setHeader("Authorization", `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`);
+ req.setHeader(
+ "Authorization",
+ `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`,
+ );
}
req.on("timeout", () => {
// set a retryable error code
statusCode = 524;
- req.destroy(new Error("HTTP request timeout, no response from server in time"));
+ req.destroy(
+ new Error("HTTP request timeout, no response from server in time"),
+ );
});
- req.on("error", err => {
+ req.on("error", (err) => {
// if the error is thrown while the request is sent, statusCode is -1 => no retry
// request timeout comes through with statusCode 524 => retry
// if the error is thrown while the response is processed, the statusCode is taken from the response => retry depends on statusCode
@@ -109,19 +134,21 @@ class HttpTransport extends HttpTransportBase {
setTimeout(() => {
retryInterval = Math.min(retryInterval * 2, 1000);
this.send(data, retryBegin, retryInterval)
- .then(() => resolve(true))
- .catch(e => reject(e));
+ .then(() => resolve(true))
+ .catch((e) => reject(e));
}, retryInterval + jitter);
} else {
reject(err);
}
});
- req.write(data, err => err ? reject(err) : () => {});
+ req.write(data, (err) => (err ? reject(err) : () => {}));
req.end();
});
}
- private createRequestOptions(timeoutMillis: number): http.RequestOptions | https.RequestOptions {
+ private createRequestOptions(
+ timeoutMillis: number,
+ ): http.RequestOptions | https.RequestOptions {
return {
//protocol: this.secure ? "https:" : "http:",
hostname: this.host,
@@ -141,7 +168,9 @@ class HttpTransport extends HttpTransportBase {
*/
private static getDefaultHttpAgent(): http.Agent {
if (!HttpTransport.DEFAULT_HTTP_AGENT) {
- HttpTransport.DEFAULT_HTTP_AGENT = new http.Agent(DEFAULT_HTTP_AGENT_CONFIG);
+ HttpTransport.DEFAULT_HTTP_AGENT = new http.Agent(
+ DEFAULT_HTTP_AGENT_CONFIG,
+ );
}
return HttpTransport.DEFAULT_HTTP_AGENT;
}
@@ -152,7 +181,9 @@ class HttpTransport extends HttpTransportBase {
*/
private static getDefaultHttpsAgent(): https.Agent {
if (!HttpTransport.DEFAULT_HTTPS_AGENT) {
- HttpTransport.DEFAULT_HTTPS_AGENT = new https.Agent(DEFAULT_HTTP_AGENT_CONFIG);
+ HttpTransport.DEFAULT_HTTPS_AGENT = new https.Agent(
+ DEFAULT_HTTP_AGENT_CONFIG,
+ );
}
return HttpTransport.DEFAULT_HTTPS_AGENT;
}
diff --git a/src/transport/http/undici.ts b/src/transport/http/undici.ts
index 43575fa..c6545cd 100644
--- a/src/transport/http/undici.ts
+++ b/src/transport/http/undici.ts
@@ -4,7 +4,11 @@ import { Agent, RetryAgent } from "undici";
import Dispatcher from "undici/types/dispatcher";
import { SenderOptions, HTTP, HTTPS } from "../../options";
-import { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT } from "./base";
+import {
+ HttpTransportBase,
+ RETRIABLE_STATUS_CODES,
+ HTTP_NO_CONTENT,
+} from "./base";
const DEFAULT_HTTP_OPTIONS: Agent.Options = {
connect: {
@@ -24,7 +28,7 @@ class UndiciTransport extends HttpTransportBase {
private static DEFAULT_HTTP_AGENT: Agent;
private readonly agent: Dispatcher;
- private readonly dispatcher : RetryAgent;
+ private readonly dispatcher: RetryAgent;
/**
* Creates an instance of Sender.
@@ -37,7 +41,10 @@ class UndiciTransport extends HttpTransportBase {
switch (options.protocol) {
case HTTP:
- this.agent = options.agent instanceof Agent ? options.agent : UndiciTransport.getDefaultHttpAgent();
+ this.agent =
+ options.agent instanceof Agent
+ ? options.agent
+ : UndiciTransport.getDefaultHttpAgent();
break;
case HTTPS:
if (options.agent instanceof Agent) {
@@ -56,7 +63,9 @@ class UndiciTransport extends HttpTransportBase {
}
break;
default:
- throw new Error("The 'protocol' has to be 'http' or 'https' for the Undici HTTP transport");
+ throw new Error(
+ "The 'protocol' has to be 'http' or 'https' for the Undici HTTP transport",
+ );
}
this.dispatcher = new RetryAgent(this.agent, {
@@ -85,7 +94,8 @@ class UndiciTransport extends HttpTransportBase {
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
} else if (this.username && this.password) {
- headers["Authorization"] = `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`;
+ headers["Authorization"] =
+ `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`;
}
const controller = new AbortController();
@@ -94,36 +104,41 @@ class UndiciTransport extends HttpTransportBase {
let responseData: Dispatcher.ResponseData;
try {
- const timeoutMillis = (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
- responseData =
- await this.dispatcher.request({
- origin: `${this.secure ? "https" : "http"}://${this.host}:${this.port}`,
- path: "/write?precision=n",
- method: "POST",
- headers,
- body: data,
- headersTimeout: this.requestTimeout,
- bodyTimeout: timeoutMillis,
- signal,
- });
+ const timeoutMillis =
+ (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
+ responseData = await this.dispatcher.request({
+ origin: `${this.secure ? "https" : "http"}://${this.host}:${this.port}`,
+ path: "/write?precision=n",
+ method: "POST",
+ headers,
+ body: data,
+ headersTimeout: this.requestTimeout,
+ bodyTimeout: timeoutMillis,
+ signal,
+ });
} catch (err) {
if (err.name === "AbortError") {
- throw new Error("HTTP request timeout, no response from server in time");
+ throw new Error(
+ "HTTP request timeout, no response from server in time",
+ );
} else {
throw err;
}
}
- const { statusCode} = responseData;
+ const { statusCode } = responseData;
const body = await responseData.body.arrayBuffer();
if (statusCode === HTTP_NO_CONTENT) {
if (body.byteLength > 0) {
- this.log("warn", `Unexpected message from server: ${Buffer.from(body).toString()}`);
+ this.log(
+ "warn",
+ `Unexpected message from server: ${Buffer.from(body).toString()}`,
+ );
}
return true;
} else {
throw new Error(
- `HTTP request failed, statusCode=${statusCode}, error=${Buffer.from(body).toString()}`,
+ `HTTP request failed, statusCode=${statusCode}, error=${Buffer.from(body).toString()}`,
);
}
}
diff --git a/src/transport/index.ts b/src/transport/index.ts
index 983af2c..d0843f1 100644
--- a/src/transport/index.ts
+++ b/src/transport/index.ts
@@ -24,7 +24,9 @@ function createTransport(options: SenderOptions): SenderTransport {
switch (options.protocol) {
case HTTP:
case HTTPS:
- return options.legacy_http ? new HttpTransport(options) : new UndiciTransport(options);
+ return options.legacy_http
+ ? new HttpTransport(options)
+ : new UndiciTransport(options);
case TCP:
case TCPS:
return new TcpTransport(options);
@@ -33,4 +35,4 @@ function createTransport(options: SenderOptions): SenderTransport {
}
}
-export { SenderTransport, createTransport }
+export { SenderTransport, createTransport };
diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts
index be6cf05..b29e367 100644
--- a/src/transport/tcp.ts
+++ b/src/transport/tcp.ts
@@ -105,7 +105,9 @@ class TcpTransport implements SenderTransport {
this.secure = true;
break;
default:
- throw new Error("The 'protocol' has to be 'tcp' or 'tcps' for the TCP transport");
+ throw new Error(
+ "The 'protocol' has to be 'tcp' or 'tcps' for the TCP transport",
+ );
}
if (!options.auth && !options.jwk) {
@@ -140,10 +142,10 @@ class TcpTransport implements SenderTransport {
this.socket = !this.secure
? net.connect(connOptions as net.NetConnectOpts)
: tls.connect(connOptions as tls.ConnectionOptions, () => {
- if (authenticated) {
- resolve(true);
- }
- });
+ if (authenticated) {
+ resolve(true);
+ }
+ });
this.socket.setKeepAlive(true);
this.socket
@@ -159,10 +161,18 @@ class TcpTransport implements SenderTransport {
}
})
.on("ready", async () => {
- this.log("info", `Successfully connected to ${connOptions.host}:${connOptions.port}`);
+ this.log(
+ "info",
+ `Successfully connected to ${connOptions.host}:${connOptions.port}`,
+ );
if (this.jwk) {
- this.log("info", `Authenticating with ${connOptions.host}:${connOptions.port}`);
- this.socket.write(`${this.jwk.kid}\n`, err => err ? reject(err) : () => {});
+ this.log(
+ "info",
+ `Authenticating with ${connOptions.host}:${connOptions.port}`,
+ );
+ this.socket.write(`${this.jwk.kid}\n`, (err) =>
+ err ? reject(err) : () => {},
+ );
} else {
authenticated = true;
if (!this.secure || !this.tlsVerify) {
@@ -172,7 +182,11 @@ class TcpTransport implements SenderTransport {
})
.on("error", (err: Error & { code: string }) => {
this.log("error", err);
- if (this.tlsVerify || !err.code || err.code !== "SELF_SIGNED_CERT_IN_CHAIN") {
+ if (
+ this.tlsVerify ||
+ !err.code ||
+ err.code !== "SELF_SIGNED_CERT_IN_CHAIN"
+ ) {
reject(err);
}
});
@@ -217,24 +231,24 @@ class TcpTransport implements SenderTransport {
if (challenge.subarray(-1).readInt8() === 10) {
const keyObject = crypto.createPrivateKey({
key: this.jwk,
- format: "jwk"
+ format: "jwk",
});
const signature = crypto.sign(
- "RSA-SHA256",
- challenge.subarray(0, challenge.length - 1),
- keyObject
+ "RSA-SHA256",
+ challenge.subarray(0, challenge.length - 1),
+ keyObject,
);
return new Promise((resolve, reject) => {
this.socket.write(
- `${Buffer.from(signature).toString("base64")}\n`,
- (err: Error) => {
- if (err) {
- reject(err);
- } else {
- resolve(true);
- }
+ `${Buffer.from(signature).toString("base64")}\n`,
+ (err: Error) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(true);
}
+ },
);
});
}
@@ -250,7 +264,7 @@ function constructAuth(options: SenderOptions) {
if (!options.username || !options.token) {
throw new Error(
"TCP transport requires a username and a private key for authentication, " +
- "please, specify the 'username' and 'token' config options",
+ "please, specify the 'username' and 'token' config options",
);
}
@@ -265,25 +279,25 @@ function constructJwk(options: SenderOptions) {
if (!options.auth.keyId) {
throw new Error(
"Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
}
if (typeof options.auth.keyId !== "string") {
throw new Error(
"Please, specify the 'keyId' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
}
if (!options.auth.token) {
throw new Error(
"Missing private key, please, specify the 'token' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
}
if (typeof options.auth.token !== "string") {
throw new Error(
"Please, specify the 'token' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
}
diff --git a/src/utils.ts b/src/utils.ts
index 1962242..e4e67fa 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -6,7 +6,7 @@ function isBoolean(value: unknown): value is boolean {
function isInteger(value: unknown, lowerBound: number): value is number {
return (
- typeof value === "number" && Number.isInteger(value) && value >= lowerBound
+ typeof value === "number" && Number.isInteger(value) && value >= lowerBound
);
}
@@ -36,4 +36,10 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
}
}
-export { isBoolean, isInteger, timestampToMicros, timestampToNanos, TimestampUnit };
+export {
+ isBoolean,
+ isInteger,
+ timestampToMicros,
+ timestampToNanos,
+ TimestampUnit,
+};
diff --git a/test/logging.test.ts b/test/logging.test.ts
index 5997b78..6e17e34 100644
--- a/test/logging.test.ts
+++ b/test/logging.test.ts
@@ -1,13 +1,21 @@
// @ts-check
-import { describe, it, beforeAll, afterAll, afterEach, expect, vi, } from "vitest";
+import {
+ describe,
+ it,
+ beforeAll,
+ afterAll,
+ afterEach,
+ expect,
+ vi,
+} from "vitest";
import { Logger } from "../src/logging";
describe("Default logging suite", function () {
- const error = vi.spyOn(console, "error").mockImplementation(() => { });
- const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
- const info = vi.spyOn(console, "info").mockImplementation(() => { });
- const debug = vi.spyOn(console, "debug").mockImplementation(() => { });
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
let log: Logger;
beforeAll(async () => {
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 5d36db7..7f00f80 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -7,7 +7,9 @@ import { Sender } from "../src";
describe("Client interop test suite", function () {
it("runs client tests as per json test config", async function () {
const testCases = JSON.parse(
- readFileSync("./questdb-client-test/ilp-client-interop-test.json").toString()
+ readFileSync(
+ "./questdb-client-test/ilp-client-interop-test.json",
+ ).toString(),
);
for (const testCase of testCases) {
@@ -97,13 +99,14 @@ describe("Sender message builder test suite (anything not covered in client inte
init_buf_size: 1024,
});
- await expect(async () =>
- await sender
- .table("tableName")
- .booleanColumn("boolCol", true)
- // @ts-expect-error - Testing invalid options
- .timestampColumn("timestampCol", 1658484765000000, "foobar")
- .atNow()
+ await expect(
+ async () =>
+ await sender
+ .table("tableName")
+ .booleanColumn("boolCol", true)
+ // @ts-expect-error - Testing invalid options
+ .timestampColumn("timestampCol", 1658484765000000, "foobar")
+ .atNow(),
).rejects.toThrow("Unknown timestamp unit: foobar");
await sender.close();
});
@@ -118,23 +121,23 @@ describe("Sender message builder test suite (anything not covered in client inte
id: string;
gridId: string;
}> = [
- {
- id: "46022e96-076f-457f-b630-51b82b871618" + i,
- gridId: "46022e96-076f-457f-b630-51b82b871618",
- },
- {
- id: "55615358-4af1-4179-9153-faaa57d71e55",
- gridId: "55615358-4af1-4179-9153-faaa57d71e55",
- },
- {
- id: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
- gridId: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
- },
- {
- id: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280",
- gridId: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280" + i,
- },
- ];
+ {
+ id: "46022e96-076f-457f-b630-51b82b871618" + i,
+ gridId: "46022e96-076f-457f-b630-51b82b871618",
+ },
+ {
+ id: "55615358-4af1-4179-9153-faaa57d71e55",
+ gridId: "55615358-4af1-4179-9153-faaa57d71e55",
+ },
+ {
+ id: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
+ gridId: "365b9cdf-3d4e-4135-9cb0-f1a65601c840",
+ },
+ {
+ id: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280",
+ gridId: "0b67ddf2-8e69-4482-bf0c-bb987ee5c280" + i,
+ },
+ ];
pages.push(pageProducts);
}
@@ -152,9 +155,9 @@ describe("Sender message builder test suite (anything not covered in client inte
}
expect(bufferContent(sender)).toBe(
'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716180\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2800\\"}]",boolCol=t\n' +
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716181\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2801\\"}]",boolCol=t\n' +
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716182\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2802\\"}]",boolCol=t\n' +
- 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716183\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2803\\"}]",boolCol=t\n',
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716181\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2801\\"}]",boolCol=t\n' +
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716182\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2802\\"}]",boolCol=t\n' +
+ 'tableName page_products="[{\\"id\\":\\"46022e96-076f-457f-b630-51b82b8716183\\",\\"gridId\\":\\"46022e96-076f-457f-b630-51b82b871618\\"},{\\"id\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\",\\"gridId\\":\\"55615358-4af1-4179-9153-faaa57d71e55\\"},{\\"id\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\",\\"gridId\\":\\"365b9cdf-3d4e-4135-9cb0-f1a65601c840\\"},{\\"id\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c280\\",\\"gridId\\":\\"0b67ddf2-8e69-4482-bf0c-bb987ee5c2803\\"}]",boolCol=t\n',
);
await sender.close();
});
@@ -438,7 +441,7 @@ describe("Sender message builder test suite (anything not covered in client inte
expect(() =>
sender.table(
"123456789012345678901234567890123456789012345678901234567890" +
- "12345678901234567890123456789012345678901234567890123456789012345678",
+ "12345678901234567890123456789012345678901234567890123456789012345678",
),
).toThrow("Table name is too long, max length is 127");
await sender.close();
@@ -517,7 +520,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.table("tableName")
.stringColumn(
"123456789012345678901234567890123456789012345678901234567890" +
- "12345678901234567890123456789012345678901234567890123456789012345678",
+ "12345678901234567890123456789012345678901234567890123456789012345678",
"value",
),
).toThrow("Column name is too long, max length is 127");
@@ -572,7 +575,9 @@ describe("Sender message builder test suite (anything not covered in client inte
.table("tableName")
.stringColumn("name", "value")
.symbol("symbolName", "symbolValue"),
- ).toThrow("Symbol can be added only after table name is set and before any column added");
+ ).toThrow(
+ "Symbol can be added only after table name is set and before any column added",
+ );
await sender.close();
});
@@ -771,7 +776,7 @@ describe("Sender message builder test suite (anything not covered in client inte
.atNow();
expect(bufferContent(sender)).toBe(
"tableName boolCol=t,timestampCol=1658484765000000t\n" +
- "tableName boolCol=f,timestampCol=1658484766000000t\n",
+ "tableName boolCol=f,timestampCol=1658484766000000t\n",
);
sender.reset();
diff --git a/test/sender.config.test.ts b/test/sender.config.test.ts
index 7c70a7c..8b7d4d1 100644
--- a/test/sender.config.test.ts
+++ b/test/sender.config.test.ts
@@ -16,28 +16,36 @@ describe("Sender configuration options suite", function () {
});
it("throws exception if the username or the token is missing when TCP transport is used", async function () {
- await expect(async () =>
- await Sender.fromConfig("tcp::addr=hostname;username=bobo;").close()
+ await expect(
+ async () =>
+ await Sender.fromConfig("tcp::addr=hostname;username=bobo;").close(),
).rejects.toThrow(
- "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
+ "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
);
- await expect(async () =>
- await Sender.fromConfig("tcp::addr=hostname;token=bobo_token;").close()
+ await expect(
+ async () =>
+ await Sender.fromConfig("tcp::addr=hostname;token=bobo_token;").close(),
).rejects.toThrow(
- "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
+ "TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
);
});
it("throws exception if tls_roots or tls_roots_password is used", async function () {
- await expect(async () =>
- await Sender.fromConfig("tcps::addr=hostname;username=bobo;tls_roots=bla;").close()
+ await expect(
+ async () =>
+ await Sender.fromConfig(
+ "tcps::addr=hostname;username=bobo;tls_roots=bla;",
+ ).close(),
).rejects.toThrow(
"'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
- await expect(async () =>
- await Sender.fromConfig("tcps::addr=hostname;token=bobo_token;tls_roots_password=bla;").close()
+ await expect(
+ async () =>
+ await Sender.fromConfig(
+ "tcps::addr=hostname;token=bobo_token;tls_roots_password=bla;",
+ ).close(),
).rejects.toThrow(
"'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
@@ -55,41 +63,45 @@ describe("Sender configuration options suite", function () {
describe("Sender options test suite", function () {
it("fails if no options defined", async function () {
- await expect(async () =>
- // @ts-expect-error - Testing invalid options
- await new Sender().close()
+ await expect(
+ async () =>
+ // @ts-expect-error - Testing invalid options
+ await new Sender().close(),
).rejects.toThrow("The 'protocol' option is mandatory");
});
it("fails if options are null", async function () {
- await expect(async () =>
- await new Sender(null).close()
- ).rejects.toThrow("The 'protocol' option is mandatory");
+ await expect(async () => await new Sender(null).close()).rejects.toThrow(
+ "The 'protocol' option is mandatory",
+ );
});
it("fails if options are undefined", async function () {
- await expect(async () =>
- await new Sender(undefined).close()
+ await expect(
+ async () => await new Sender(undefined).close(),
).rejects.toThrow("The 'protocol' option is mandatory");
});
it("fails if options are empty", async function () {
- await expect(async () =>
+ await expect(
+ async () =>
// @ts-expect-error - Testing invalid options
- await new Sender({}).close()
+ await new Sender({}).close(),
).rejects.toThrow("The 'protocol' option is mandatory");
});
it("fails if protocol option is missing", async function () {
- await expect(async () =>
+ await expect(
+ async () =>
// @ts-expect-error - Testing invalid options
- await new Sender({ host: "host" }).close()
+ await new Sender({ host: "host" }).close(),
).rejects.toThrow("The 'protocol' option is mandatory");
});
it("fails if protocol option is invalid", async function () {
- await expect(async () =>
- await new Sender({ protocol: "abcd", host: "hostname" }).close()
+ await expect(
+ async () =>
+ await new Sender({ protocol: "abcd", host: "hostname" }).close(),
).rejects.toThrow("Invalid protocol: 'abcd'");
});
@@ -144,10 +156,15 @@ describe("Sender options test suite", function () {
});
it("sets the requested buffer size if 'bufferSize' is set, but warns that it is deprecated", async function () {
- const log = (level: "error" | "warn" | "info" | "debug", message: string | Error) => {
+ const log = (
+ level: "error" | "warn" | "info" | "debug",
+ message: string | Error,
+ ) => {
if (level !== "debug") {
expect(level).toBe("warn");
- expect(message).toMatch("Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'");
+ expect(message).toMatch(
+ "Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'",
+ );
}
};
const sender = new Sender({
@@ -162,10 +179,15 @@ describe("Sender options test suite", function () {
});
it("warns about deprecated option 'copy_buffer'", async function () {
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
+ const log = (
+ level: "error" | "warn" | "info" | "debug",
+ message: string,
+ ) => {
if (level !== "debug") {
expect(level).toBe("warn");
- expect(message).toMatch("Option 'copy_buffer' is not supported anymore, please, remove it");
+ expect(message).toMatch(
+ "Option 'copy_buffer' is not supported anymore, please, remove it",
+ );
}
};
const sender = new Sender({
@@ -179,10 +201,15 @@ describe("Sender options test suite", function () {
});
it("warns about deprecated option 'copyBuffer'", async function () {
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
+ const log = (
+ level: "error" | "warn" | "info" | "debug",
+ message: string,
+ ) => {
if (level !== "debug") {
expect(level).toBe("warn");
- expect(message).toMatch("Option 'copyBuffer' is not supported anymore, please, remove it");
+ expect(message).toMatch(
+ "Option 'copyBuffer' is not supported anymore, please, remove it",
+ );
}
};
const sender = new Sender({
@@ -212,14 +239,17 @@ describe("Sender options test suite", function () {
});
it("throws error if initial buffer size is greater than max_buf_size", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "http",
- host: "host",
- max_buf_size: 8192,
- init_buf_size: 16384,
- }).close()
- ).rejects.toThrow("Max buffer size is 8192 bytes, requested buffer size: 16384")
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "http",
+ host: "host",
+ max_buf_size: 8192,
+ init_buf_size: 16384,
+ }).close(),
+ ).rejects.toThrow(
+ "Max buffer size is 8192 bytes, requested buffer size: 16384",
+ );
});
it("sets default max buffer size if max_buf_size is set to null", async function () {
@@ -260,7 +290,7 @@ describe("Sender options test suite", function () {
});
it("uses the required log function if it is set", async function () {
- const testFunc = () => { };
+ const testFunc = () => {};
const sender = new Sender({
protocol: "http",
host: "host",
@@ -296,98 +326,104 @@ describe("Sender options test suite", function () {
describe("Sender auth config checks suite", function () {
it("requires a username for authentication", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- token: "privateKey",
- },
- }).close()
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ token: "privateKey",
+ },
+ }).close(),
).rejects.toThrow(
"Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
});
it("requires a non-empty username", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "",
- token: "privateKey",
- },
- }).close()
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "",
+ token: "privateKey",
+ },
+ }).close(),
).rejects.toThrow(
"Missing username, please, specify the 'keyId' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
});
it("requires that the username is a string", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- // @ts-expect-error - Testing invalid options
- keyId: 23,
- token: "privateKey",
- },
- }).close()
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ // @ts-expect-error - Testing invalid options
+ keyId: 23,
+ token: "privateKey",
+ },
+ }).close(),
).rejects.toThrow(
"Please, specify the 'keyId' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
});
it("requires a private key for authentication", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "username",
- },
- }).close()
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "username",
+ },
+ }).close(),
).rejects.toThrow(
"Missing private key, please, specify the 'token' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
});
it("requires a non-empty private key", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "username",
- token: "",
- },
- }).close()
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "username",
+ token: "",
+ },
+ }).close(),
).rejects.toThrow(
"Missing private key, please, specify the 'token' property of the 'auth' config option. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
});
it("requires that the private key is a string", async function () {
- await expect(async () =>
- await new Sender({
- protocol: "tcp",
- host: "host",
- auth: {
- keyId: "username",
- // @ts-expect-error - Testing invalid options
- token: true,
- },
- }).close()
+ await expect(
+ async () =>
+ await new Sender({
+ protocol: "tcp",
+ host: "host",
+ auth: {
+ keyId: "username",
+ // @ts-expect-error - Testing invalid options
+ token: true,
+ },
+ }).close(),
).rejects.toThrow(
"Please, specify the 'token' property of the 'auth' config option as a string. " +
- "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
+ "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})",
);
});
});
diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts
index aa2b5ca..eff352e 100644
--- a/test/sender.integration.test.ts
+++ b/test/sender.integration.test.ts
@@ -37,16 +37,25 @@ describe("Sender tests with containerized QuestDB instance", () => {
resolve(JSON.parse(Buffer.concat(body).toString()));
});
} else {
- reject(new Error(`HTTP request failed, statusCode=${response.statusCode}, query=${query}`));
+ reject(
+ new Error(
+ `HTTP request failed, statusCode=${response.statusCode}, query=${query}`,
+ ),
+ );
}
});
- req.on("error", error => reject(error));
+ req.on("error", (error) => reject(error));
req.end();
});
}
- async function runSelect(container: StartedTestContainer, select: string, expectedCount: number, timeout = 60000) {
+ async function runSelect(
+ container: StartedTestContainer,
+ select: string,
+ expectedCount: number,
+ timeout = 60000,
+ ) {
const interval = 500;
const num = timeout / interval;
let selectResult: any;
@@ -62,8 +71,17 @@ describe("Sender tests with containerized QuestDB instance", () => {
);
}
- async function waitForTable(container: StartedTestContainer, tableName: string, timeout = 30000) {
- await runSelect(container, `tables() where table_name='${tableName}'`, 1, timeout);
+ async function waitForTable(
+ container: StartedTestContainer,
+ tableName: string,
+ timeout = 30000,
+ ) {
+ await runSelect(
+ container,
+ `tables() where table_name='${tableName}'`,
+ 1,
+ timeout,
+ );
}
beforeAll(async () => {
@@ -106,7 +124,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
await sender.flush();
// wait for the table
- await waitForTable(container, tableName)
+ await waitForTable(container, tableName);
// query table
const select1Result = await runSelect(container, tableName, 1);
@@ -176,7 +194,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
.at(1658484765000000000n, "ns");
// wait for the table
- await waitForTable(container, tableName)
+ await waitForTable(container, tableName);
// query table
const select1Result = await runSelect(container, tableName, 1);
@@ -248,7 +266,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
.at(1658484765000000000n, "ns");
// wait for the table
- await waitForTable(container, tableName)
+ await waitForTable(container, tableName);
// query table
const select1Result = await runSelect(container, tableName, 1);
@@ -330,7 +348,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
}
// wait for the table
- await waitForTable(container, tableName)
+ await waitForTable(container, tableName);
// query table
const selectQuery = `${tableName} order by temperature`;
@@ -375,7 +393,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
await sender.flush();
// Wait for the table
- await waitForTable(container, tableName)
+ await waitForTable(container, tableName);
// Query table and verify count
const selectQuery = `SELECT id FROM ${tableName}`;
diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts
index 39edd9d..ff22b96 100644
--- a/test/sender.transport.test.ts
+++ b/test/sender.transport.test.ts
@@ -196,7 +196,7 @@ describe("Sender HTTP suite", function () {
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=1000`,
);
await expect(sendData(sender)).rejects.toThrowError(
- "HTTP request timeout, no response from server in time"
+ "HTTP request timeout, no response from server in time",
);
await sender.close();
});
@@ -262,8 +262,8 @@ describe("Sender HTTP suite", function () {
const agent = new Agent({ pipelining: 3 });
const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
- { agent: agent },
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
+ { agent: agent },
);
await sendData(sender);
@@ -278,13 +278,13 @@ describe("Sender HTTP suite", function () {
await agent.destroy();
});
- it('supports custom legacy HTTP agent', async function () {
+ it("supports custom legacy HTTP agent", async function () {
mockHttp.reset();
const agent = new http.Agent({ maxSockets: 128 });
const sender = Sender.fromConfig(
- `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};legacy_http=on`,
- { agent: agent },
+ `http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};legacy_http=on`,
+ { agent: agent },
);
await sendData(sender);
expect(mockHttp.numOfRequests).toBe(1);
@@ -464,8 +464,8 @@ describe("Sender TCP suite", function () {
});
it("fails to connect without hostname and port", async function () {
- await expect(async () =>
- await new Sender({ protocol: "tcp" }).close()
+ await expect(
+ async () => await new Sender({ protocol: "tcp" }).close(),
).rejects.toThrow("The 'host' option is mandatory");
});
@@ -481,9 +481,9 @@ describe("Sender TCP suite", function () {
it("guards against multiple connect calls", async function () {
const proxy = await createProxy(true, proxyOptions);
const sender = await createSender(AUTH, true);
- await expect(async () =>
- await sender.connect()
- ).rejects.toThrow("Sender connected already");
+ await expect(async () => await sender.connect()).rejects.toThrow(
+ "Sender connected already",
+ );
await sender.close();
await proxy.stop();
});
@@ -497,8 +497,8 @@ describe("Sender TCP suite", function () {
auth: AUTH,
tls_ca: "test/certs/ca/ca.crt",
});
- await expect(async () =>
- await Promise.all([sender.connect(), sender.connect()])
+ await expect(
+ async () => await Promise.all([sender.connect(), sender.connect()]),
).rejects.toThrow("Sender connected already");
await sender.close();
await proxy.stop();
@@ -509,8 +509,8 @@ describe("Sender TCP suite", function () {
const senderCertCheckFail = Sender.fromConfig(
`tcps::addr=${PROXY_HOST}:${PROXY_PORT}`,
);
- await expect(async () =>
- await senderCertCheckFail.connect()
+ await expect(
+ async () => await senderCertCheckFail.connect(),
).rejects.toThrow("self-signed certificate in certificate chain");
await senderCertCheckFail.close();
@@ -544,7 +544,10 @@ describe("Sender TCP suite", function () {
"Successfully connected to localhost:9088",
/^Connection to .*1:9088 is closed$/,
];
- const log = (level: "error" | "warn" | "info" | "debug", message: string) => {
+ const log = (
+ level: "error" | "warn" | "info" | "debug",
+ message: string,
+ ) => {
if (level !== "debug") {
expect(level).toBe("info");
expect(message).toMatch(expectedMessages.shift());
diff --git a/test/util/mockhttp.ts b/test/util/mockhttp.ts
index 2341df6..45de985 100644
--- a/test/util/mockhttp.ts
+++ b/test/util/mockhttp.ts
@@ -2,12 +2,12 @@ import http from "node:http";
import https from "node:https";
type MockConfig = {
- responseDelays?: number[],
- responseCodes?: number[],
- username?: string,
- password?: string,
- token?: string,
-}
+ responseDelays?: number[];
+ responseCodes?: number[];
+ username?: string;
+ password?: string;
+ token?: string;
+};
class MockHttp {
server: http.Server | https.Server;
@@ -23,40 +23,47 @@ class MockHttp {
this.numOfRequests = 0;
}
- async start(listenPort: number, secure: boolean = false, options?: Record): Promise {
+ async start(
+ listenPort: number,
+ secure: boolean = false,
+ options?: Record,
+ ): Promise {
const serverCreator = secure ? https.createServer : http.createServer;
// @ts-expect-error - Testing different options, so typing is not important
- this.server = serverCreator(options, (req: http.IncomingMessage, res: http.ServerResponse) => {
- const authFailed = checkAuthHeader(this.mockConfig, req);
+ this.server = serverCreator(
+ options,
+ (req: http.IncomingMessage, res: http.ServerResponse) => {
+ const authFailed = checkAuthHeader(this.mockConfig, req);
- const body: Uint8Array[] = [];
- req.on("data", (chunk: Uint8Array) => {
- body.push(chunk);
- });
+ const body: Uint8Array[] = [];
+ req.on("data", (chunk: Uint8Array) => {
+ body.push(chunk);
+ });
- req.on("end", async () => {
- console.info(`Received data: ${Buffer.concat(body)}`);
- this.numOfRequests++;
+ req.on("end", async () => {
+ console.info(`Received data: ${Buffer.concat(body)}`);
+ this.numOfRequests++;
- const delay =
- this.mockConfig.responseDelays &&
+ const delay =
+ this.mockConfig.responseDelays &&
this.mockConfig.responseDelays.length > 0
- ? this.mockConfig.responseDelays.pop()
- : undefined;
- if (delay) {
- await sleep(delay);
- }
+ ? this.mockConfig.responseDelays.pop()
+ : undefined;
+ if (delay) {
+ await sleep(delay);
+ }
- const responseCode = authFailed
- ? 401
- : this.mockConfig.responseCodes &&
- this.mockConfig.responseCodes.length > 0
- ? this.mockConfig.responseCodes.pop()
- : 204;
- res.writeHead(responseCode);
- res.end();
- });
- });
+ const responseCode = authFailed
+ ? 401
+ : this.mockConfig.responseCodes &&
+ this.mockConfig.responseCodes.length > 0
+ ? this.mockConfig.responseCodes.pop()
+ : 204;
+ res.writeHead(responseCode);
+ res.end();
+ });
+ },
+ );
return new Promise((resolve, reject) => {
this.server.listen(listenPort, () => {
@@ -64,7 +71,7 @@ class MockHttp {
resolve(true);
});
- this.server.on("error", e => {
+ this.server.on("error", (e) => {
console.error(`server error: ${e}`);
reject(e);
});
diff --git a/test/util/mockproxy.ts b/test/util/mockproxy.ts
index ecc9c9c..0a270e5 100644
--- a/test/util/mockproxy.ts
+++ b/test/util/mockproxy.ts
@@ -7,14 +7,14 @@ const CHALLENGE_LENGTH = 512;
type MockConfig = {
auth?: boolean;
assertions?: boolean;
-}
+};
class MockProxy {
mockConfig: MockConfig;
dataSentToRemote: string[];
hasSentChallenge: boolean;
client: Socket;
- server: net.Server | tls.Server
+ server: net.Server | tls.Server;
constructor(mockConfig: MockConfig) {
if (!mockConfig) {
diff --git a/test/util/proxy.ts b/test/util/proxy.ts
index 59c3076..9117813 100644
--- a/test/util/proxy.ts
+++ b/test/util/proxy.ts
@@ -8,7 +8,7 @@ import { write, listen, shutdown, connect, close } from "./proxyfunctions";
class Proxy {
client: Socket;
remote: Socket;
- server: net.Server | tls.Server
+ server: net.Server | tls.Server;
constructor() {
this.remote = new Socket();
@@ -27,7 +27,12 @@ class Proxy {
});
}
- async start(listenPort: number, remotePort: number, remoteHost: string, tlsOptions: Record) {
+ async start(
+ listenPort: number,
+ remotePort: number,
+ remoteHost: string,
+ tlsOptions: Record,
+ ) {
return new Promise((resolve) => {
this.remote.on("ready", async () => {
console.info("remote connection ready");
diff --git a/test/util/proxyfunctions.ts b/test/util/proxyfunctions.ts
index bc23f18..31e33a6 100644
--- a/test/util/proxyfunctions.ts
+++ b/test/util/proxyfunctions.ts
@@ -1,17 +1,22 @@
import net, { Socket } from "node:net";
import tls, { TLSSocket } from "node:tls";
import { Proxy } from "./proxy";
-import {MockProxy} from "./mockproxy";
+import { MockProxy } from "./mockproxy";
const LOCALHOST = "localhost";
async function write(socket: Socket, data: string) {
return new Promise((resolve, reject) => {
- socket.write(data, "utf8", (err: Error) => err ? reject(err): resolve());
+ socket.write(data, "utf8", (err: Error) => (err ? reject(err) : resolve()));
});
}
-async function listen(proxy: Proxy | MockProxy, listenPort: number, dataHandler: (data: string) => void, tlsOptions: tls.TlsOptions) {
+async function listen(
+ proxy: Proxy | MockProxy,
+ listenPort: number,
+ dataHandler: (data: string) => void,
+ tlsOptions: tls.TlsOptions,
+) {
return new Promise((resolve) => {
const clientConnHandler = (client: Socket | TLSSocket) => {
console.info("client connected");
@@ -39,7 +44,10 @@ async function listen(proxy: Proxy | MockProxy, listenPort: number, dataHandler:
});
}
-async function shutdown(proxy: Proxy | MockProxy, onServerClose = async () => {}) {
+async function shutdown(
+ proxy: Proxy | MockProxy,
+ onServerClose = async () => {},
+) {
console.info("closing proxy");
return new Promise((resolve) => {
proxy.server.close(async () => {
From 7a70a6d06e1fe2787e1af43a303c1fb05aba35c4 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 24 Jul 2025 09:57:19 +0100
Subject: [PATCH 07/38] feat(nodejs): protocol version option, binary protocol
for doubles
---
src/buffer/base.ts | 441 +++++++++++
src/buffer/bufferv1.ts | 26 +
src/buffer/bufferv2.ts | 30 +
src/buffer/index.ts | 372 ++-------
src/options.ts | 94 ++-
src/sender.ts | 14 +-
src/utils.ts | 17 +
test/options.test.ts | 1247 ++++++++++++++++++++-----------
test/sender.buffer.test.ts | 55 +-
test/sender.config.test.ts | 100 ++-
test/sender.integration.test.ts | 31 +-
test/sender.transport.test.ts | 122 +--
test/util/mockhttp.ts | 85 ++-
13 files changed, 1736 insertions(+), 898 deletions(-)
create mode 100644 src/buffer/base.ts
create mode 100644 src/buffer/bufferv1.ts
create mode 100644 src/buffer/bufferv2.ts
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
new file mode 100644
index 0000000..442d7c5
--- /dev/null
+++ b/src/buffer/base.ts
@@ -0,0 +1,441 @@
+// @ts-check
+import { Buffer } from "node:buffer";
+
+import { log, Logger } from "../logging";
+import { validateColumnName, validateTableName } from "../validation";
+import { SenderOptions } from "../options";
+import {
+ SenderBuffer,
+ DEFAULT_BUFFER_SIZE,
+ DEFAULT_MAX_BUFFER_SIZE,
+} from "./index";
+import {
+ isInteger,
+ timestampToMicros,
+ timestampToNanos,
+ TimestampUnit,
+} from "../utils";
+
+const DEFAULT_MAX_NAME_LENGTH = 127;
+
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * If no custom agent is configured, the Sender will use its own agent which overrides some default values
+ * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
+ *
+ */
+abstract class SenderBufferBase implements SenderBuffer {
+ private bufferSize: number;
+ private readonly maxBufferSize: number;
+ private buffer: Buffer;
+ private position: number;
+ private endOfLastRow: number;
+
+ private hasTable: boolean;
+ private hasSymbols: boolean;
+ private hasColumns: boolean;
+
+ private readonly maxNameLength: number;
+
+ protected readonly log: Logger;
+
+ /**
+ * Creates an instance of Sender.
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
+ */
+ protected constructor(options: SenderOptions) {
+ this.log = options && typeof options.log === "function" ? options.log : log;
+ SenderOptions.resolveDeprecated(options, this.log);
+
+ this.maxNameLength =
+ options && isInteger(options.max_name_len, 1)
+ ? options.max_name_len
+ : DEFAULT_MAX_NAME_LENGTH;
+
+ this.maxBufferSize =
+ options && isInteger(options.max_buf_size, 1)
+ ? options.max_buf_size
+ : DEFAULT_MAX_BUFFER_SIZE;
+ this.resize(
+ options && isInteger(options.init_buf_size, 1)
+ ? options.init_buf_size
+ : DEFAULT_BUFFER_SIZE,
+ );
+
+ this.reset();
+ }
+
+ /**
+ * Extends the size of the sender's buffer.
+ * Can be used to increase the size of buffer if overflown.
+ * The buffer's content is copied into the new buffer.
+ *
+ * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
+ */
+ private resize(bufferSize: number) {
+ if (bufferSize > this.maxBufferSize) {
+ throw new Error(
+ `Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`,
+ );
+ }
+ this.bufferSize = bufferSize;
+ // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
+ // longer than the size of the buffer. It simply just writes whatever it can, and returns.
+ // If we can write into the extra byte, that indicates buffer overflow.
+ // See the check in the write() function.
+ const newBuffer = Buffer.alloc(this.bufferSize + 1, 0);
+ if (this.buffer) {
+ this.buffer.copy(newBuffer);
+ }
+ this.buffer = newBuffer;
+ }
+
+ /**
+ * Resets the buffer, data added to the buffer will be lost.
+ * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ *
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ reset(): SenderBuffer {
+ this.position = 0;
+ this.startNewRow();
+ return this;
+ }
+
+ private startNewRow() {
+ this.endOfLastRow = this.position;
+ this.hasTable = false;
+ this.hasSymbols = false;
+ this.hasColumns = false;
+ }
+
+ /**
+ * @ignore
+ * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
+ * The returned buffer is backed by the sender's buffer.
+ * Used only in tests.
+ */
+ toBufferView(pos = this.endOfLastRow): Buffer {
+ return pos > 0 ? this.buffer.subarray(0, pos) : null;
+ }
+
+ /**
+ * @ignore
+ * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
+ * The returned buffer is a copy of the sender's buffer.
+ * It also compacts the Sender's buffer.
+ */
+ toBufferNew(pos = this.endOfLastRow): Buffer | null {
+ if (pos > 0) {
+ const data = Buffer.allocUnsafe(pos);
+ this.buffer.copy(data, 0, 0, pos);
+ this.compact();
+ return data;
+ }
+ return null;
+ }
+
+ /**
+ * Write the table name into the buffer of the sender.
+ *
+ * @param {string} table - Table name.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ table(table: string): SenderBuffer {
+ if (typeof table !== "string") {
+ throw new Error(`Table name must be a string, received ${typeof table}`);
+ }
+ if (this.hasTable) {
+ throw new Error("Table name has already been set");
+ }
+ validateTableName(table, this.maxNameLength);
+ this.checkCapacity([table], table.length);
+ this.writeEscaped(table);
+ this.hasTable = true;
+ return this;
+ }
+
+ /**
+ * Write a symbol name and value into the buffer of the sender.
+ *
+ * @param {string} name - Symbol name.
+ * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ symbol(name: string, value: unknown): SenderBuffer {
+ if (typeof name !== "string") {
+ throw new Error(`Symbol name must be a string, received ${typeof name}`);
+ }
+ if (!this.hasTable || this.hasColumns) {
+ throw new Error(
+ "Symbol can be added only after table name is set and before any column added",
+ );
+ }
+ const valueStr = value.toString();
+ this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length);
+ this.write(",");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ this.writeEscaped(valueStr);
+ this.hasSymbols = true;
+ return this;
+ }
+
+ /**
+ * Write a string column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {string} value - Column value, accepts only string values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ stringColumn(name: string, value: string): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ this.checkCapacity([value], 2 + value.length);
+ this.write('"');
+ this.writeEscaped(value, true);
+ this.write('"');
+ },
+ "string",
+ );
+ return this;
+ }
+
+ /**
+ * Write a boolean column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {boolean} value - Column value, accepts only boolean values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ booleanColumn(name: string, value: boolean): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ this.checkCapacity([], 1);
+ this.write(value ? "t" : "f");
+ },
+ "boolean",
+ );
+ return this;
+ }
+
+ /**
+ * Write a float column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ abstract floatColumn(name: string, value: number): SenderBuffer;
+
+ /**
+ * Write an integer column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ intColumn(name: string, value: number): SenderBuffer {
+ if (!Number.isInteger(value)) {
+ throw new Error(`Value must be an integer, received ${value}`);
+ }
+ this.writeColumn(name, value, () => {
+ const valueStr = value.toString();
+ this.checkCapacity([valueStr], 1);
+ this.write(valueStr);
+ this.write("i");
+ });
+ return this;
+ }
+
+ /**
+ * Write a timestamp column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
+ * @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
+ * @return {Sender} Returns with a reference to this sender.
+ */
+ timestampColumn(
+ name: string,
+ value: number | bigint,
+ unit: TimestampUnit = "us",
+ ): SenderBuffer {
+ if (typeof value !== "bigint" && !Number.isInteger(value)) {
+ throw new Error(`Value must be an integer or BigInt, received ${value}`);
+ }
+ this.writeColumn(name, value, () => {
+ const valueMicros = timestampToMicros(BigInt(value), unit);
+ const valueStr = valueMicros.toString();
+ this.checkCapacity([valueStr], 1);
+ this.write(valueStr);
+ this.write("t");
+ });
+ return this;
+ }
+
+ /**
+ * Closing the row after writing the designated timestamp into the buffer of the sender.
+ *
+ * @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
+ * @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
+ */
+ at(timestamp: number | bigint, unit: TimestampUnit = "us") {
+ if (!this.hasSymbols && !this.hasColumns) {
+ throw new Error(
+ "The row must have a symbol or column set before it is closed",
+ );
+ }
+ if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
+ throw new Error(
+ `Designated timestamp must be an integer or BigInt, received ${timestamp}`,
+ );
+ }
+ const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
+ const timestampStr = timestampNanos.toString();
+ this.checkCapacity([timestampStr], 2);
+ this.write(" ");
+ this.write(timestampStr);
+ this.write("\n");
+ this.startNewRow();
+ }
+
+ /**
+ * Closing the row without writing designated timestamp into the buffer of the sender.
+ * Designated timestamp will be populated by the server on this record.
+ */
+ atNow() {
+ if (!this.hasSymbols && !this.hasColumns) {
+ throw new Error(
+ "The row must have a symbol or column set before it is closed",
+ );
+ }
+ this.checkCapacity([], 1);
+ this.write("\n");
+ this.startNewRow();
+ }
+
+ protected checkCapacity(data: string[], base = 0) {
+ let length = base;
+ for (const str of data) {
+ length += Buffer.byteLength(str, "utf8");
+ }
+ if (this.position + length > this.bufferSize) {
+ let newSize = this.bufferSize;
+ do {
+ newSize += this.bufferSize;
+ } while (this.position + length > newSize);
+ this.resize(newSize);
+ }
+ }
+
+ private compact() {
+ if (this.endOfLastRow > 0) {
+ this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
+ this.position = this.position - this.endOfLastRow;
+ this.endOfLastRow = 0;
+ }
+ }
+
+ protected writeColumn(
+ name: string,
+ value: unknown,
+ writeValue: () => void,
+ valueType?: string,
+ ) {
+ if (typeof name !== "string") {
+ throw new Error(`Column name must be a string, received ${typeof name}`);
+ }
+ if (valueType && typeof value !== valueType) {
+ throw new Error(
+ `Column value must be of type ${valueType}, received ${typeof value}`,
+ );
+ }
+ if (!this.hasTable) {
+ throw new Error("Column can be set only after table name is set");
+ }
+ this.checkCapacity([name], 2 + name.length);
+ this.write(this.hasColumns ? "," : " ");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ writeValue();
+ this.hasColumns = true;
+ }
+
+ protected write(data: string) {
+ this.position += this.buffer.write(data, this.position);
+ if (this.position > this.bufferSize) {
+ // should never happen, if checkCapacity() is correctly used
+ throw new Error(
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
+ );
+ }
+ }
+
+ protected writeByte(data: number) {
+ this.position = this.buffer.writeInt8(data, this.position);
+ if (this.position > this.bufferSize) {
+ // should never happen, if checkCapacity() is correctly used
+ throw new Error(
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
+ );
+ }
+ }
+
+ protected writeDouble(data: number) {
+ this.position = this.buffer.writeDoubleLE(data, this.position);
+ if (this.position > this.bufferSize) {
+ // should never happen, if checkCapacity() is correctly used
+ throw new Error(
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
+ );
+ }
+ }
+
+ private writeEscaped(data: string, quoted = false) {
+ for (const ch of data) {
+ if (ch > "\\") {
+ this.write(ch);
+ continue;
+ }
+
+ switch (ch) {
+ case " ":
+ case ",":
+ case "=":
+ if (!quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\n":
+ case "\r":
+ this.write("\\");
+ this.write(ch);
+ break;
+ case '"':
+ if (quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\\":
+ this.write("\\\\");
+ break;
+ default:
+ this.write(ch);
+ break;
+ }
+ }
+ }
+}
+
+export { SenderBufferBase };
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
new file mode 100644
index 0000000..aa2a37e
--- /dev/null
+++ b/src/buffer/bufferv1.ts
@@ -0,0 +1,26 @@
+// @ts-check
+import { SenderOptions } from "../options";
+import { SenderBuffer } from "./index";
+import { SenderBufferBase } from "./base";
+
+class SenderBufferV1 extends SenderBufferBase {
+ constructor(options: SenderOptions) {
+ super(options);
+ }
+
+ floatColumn(name: string, value: number): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ const valueStr = value.toString();
+ this.checkCapacity([valueStr]);
+ this.write(valueStr);
+ },
+ "number",
+ );
+ return this;
+ }
+}
+
+export { SenderBufferV1 };
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
new file mode 100644
index 0000000..e21fdb1
--- /dev/null
+++ b/src/buffer/bufferv2.ts
@@ -0,0 +1,30 @@
+// @ts-check
+import { SenderOptions } from "../options";
+import { SenderBuffer } from "./index";
+import { SenderBufferBase } from "./base";
+
+const ENTITY_TYPE_DOUBLE: number = 16;
+const EQUALS_SIGN: number = "=".charCodeAt(0);
+
+class SenderBufferV2 extends SenderBufferBase {
+ constructor(options: SenderOptions) {
+ super(options);
+ }
+
+ floatColumn(name: string, value: number): SenderBuffer {
+ this.writeColumn(
+ name,
+ value,
+ () => {
+ this.checkCapacity([], 10);
+ this.writeByte(EQUALS_SIGN);
+ this.writeByte(ENTITY_TYPE_DOUBLE);
+ this.writeDouble(value);
+ },
+ "number",
+ );
+ return this;
+ }
+}
+
+export { SenderBufferV2 };
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index 383f23f..fdac848 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -1,113 +1,53 @@
// @ts-check
import { Buffer } from "node:buffer";
-import { log, Logger } from "../logging";
-import { validateColumnName, validateTableName } from "../validation";
-import { SenderOptions } from "../options";
import {
- isInteger,
- timestampToMicros,
- timestampToNanos,
- TimestampUnit,
-} from "../utils";
-
-const DEFAULT_MAX_NAME_LENGTH = 127;
+ SenderOptions,
+ PROTOCOL_VERSION_V1,
+ PROTOCOL_VERSION_V2,
+ PROTOCOL_VERSION_AUTO,
+} from "../options";
+import { TimestampUnit } from "../utils";
+import { SenderBufferV1 } from "./bufferv1";
+import { SenderBufferV2 } from "./bufferv2";
const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
+function createBuffer(options: SenderOptions): SenderBuffer {
+ switch (options.protocol_version) {
+ case PROTOCOL_VERSION_V2:
+ return new SenderBufferV2(options);
+ case PROTOCOL_VERSION_V1:
+ return new SenderBufferV1(options);
+ case PROTOCOL_VERSION_AUTO:
+ case undefined:
+ case null:
+ case "":
+ throw new Error(
+ "Provide the 'protocol_version' option, or call 'await SenderOptions.resolveAuto(options)' first",
+ );
+ default:
+ throw new Error(
+ "Unsupported protocol version: " + options.protocol_version,
+ );
+ }
+}
+
/** @classdesc
* The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
* If no custom agent is configured, the Sender will use its own agent which overrides some default values
* of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
*
*/
-class SenderBuffer {
- private bufferSize: number;
- private readonly maxBufferSize: number;
- private buffer: Buffer;
- private position: number;
- private endOfLastRow: number;
-
- private hasTable: boolean;
- private hasSymbols: boolean;
- private hasColumns: boolean;
-
- private readonly maxNameLength: number;
-
- private readonly log: Logger;
-
- /**
- * Creates an instance of Sender.
- *
- * @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
- */
- constructor(options: SenderOptions) {
- this.log = options && typeof options.log === "function" ? options.log : log;
- SenderOptions.resolveDeprecated(options, this.log);
-
- this.maxNameLength =
- options && isInteger(options.max_name_len, 1)
- ? options.max_name_len
- : DEFAULT_MAX_NAME_LENGTH;
-
- this.maxBufferSize =
- options && isInteger(options.max_buf_size, 1)
- ? options.max_buf_size
- : DEFAULT_MAX_BUFFER_SIZE;
- this.resize(
- options && isInteger(options.init_buf_size, 1)
- ? options.init_buf_size
- : DEFAULT_BUFFER_SIZE,
- );
-
- this.reset();
- }
-
- /**
- * Extends the size of the sender's buffer.
- * Can be used to increase the size of buffer if overflown.
- * The buffer's content is copied into the new buffer.
- *
- * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
- */
- private resize(bufferSize: number) {
- if (bufferSize > this.maxBufferSize) {
- throw new Error(
- `Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`,
- );
- }
- this.bufferSize = bufferSize;
- // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
- // longer than the size of the buffer. It simply just writes whatever it can, and returns.
- // If we can write into the extra byte, that indicates buffer overflow.
- // See the check in the write() function.
- const newBuffer = Buffer.alloc(this.bufferSize + 1, 0, "utf8");
- if (this.buffer) {
- this.buffer.copy(newBuffer);
- }
- this.buffer = newBuffer;
- }
-
+interface SenderBuffer {
/**
* Resets the buffer, data added to the buffer will be lost.
* In other words it clears the buffer and sets the writing position to the beginning of the buffer.
*
* @return {Sender} Returns with a reference to this sender.
*/
- reset(): SenderBuffer {
- this.position = 0;
- this.startNewRow();
- return this;
- }
-
- private startNewRow() {
- this.endOfLastRow = this.position;
- this.hasTable = false;
- this.hasSymbols = false;
- this.hasColumns = false;
- }
+ reset(): SenderBuffer;
/**
* @ignore
@@ -115,9 +55,7 @@ class SenderBuffer {
* The returned buffer is backed by the sender's buffer.
* Used only in tests.
*/
- toBufferView(pos = this.endOfLastRow): Buffer {
- return pos > 0 ? this.buffer.subarray(0, pos) : null;
- }
+ toBufferView(pos?: number): Buffer;
/**
* @ignore
@@ -125,15 +63,7 @@ class SenderBuffer {
* The returned buffer is a copy of the sender's buffer.
* It also compacts the Sender's buffer.
*/
- toBufferNew(pos = this.endOfLastRow): Buffer | null {
- if (pos > 0) {
- const data = Buffer.allocUnsafe(pos);
- this.buffer.copy(data, 0, 0, pos);
- this.compact();
- return data;
- }
- return null;
- }
+ toBufferNew(pos?: number): Buffer | null;
/**
* Write the table name into the buffer of the sender.
@@ -141,19 +71,7 @@ class SenderBuffer {
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
*/
- table(table: string): SenderBuffer {
- if (typeof table !== "string") {
- throw new Error(`Table name must be a string, received ${typeof table}`);
- }
- if (this.hasTable) {
- throw new Error("Table name has already been set");
- }
- validateTableName(table, this.maxNameLength);
- this.checkCapacity([table], table.length);
- this.writeEscaped(table);
- this.hasTable = true;
- return this;
- }
+ table(table: string): SenderBuffer;
/**
* Write a symbol name and value into the buffer of the sender.
@@ -162,25 +80,7 @@ class SenderBuffer {
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
* @return {Sender} Returns with a reference to this sender.
*/
- symbol(name: string, value: unknown): SenderBuffer {
- if (typeof name !== "string") {
- throw new Error(`Symbol name must be a string, received ${typeof name}`);
- }
- if (!this.hasTable || this.hasColumns) {
- throw new Error(
- "Symbol can be added only after table name is set and before any column added",
- );
- }
- const valueStr = value.toString();
- this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length);
- this.write(",");
- validateColumnName(name, this.maxNameLength);
- this.writeEscaped(name);
- this.write("=");
- this.writeEscaped(valueStr);
- this.hasSymbols = true;
- return this;
- }
+ symbol(name: string, value: unknown): SenderBuffer;
/**
* Write a string column with its value into the buffer of the sender.
@@ -189,20 +89,7 @@ class SenderBuffer {
* @param {string} value - Column value, accepts only string values.
* @return {Sender} Returns with a reference to this sender.
*/
- stringColumn(name: string, value: string): SenderBuffer {
- this.writeColumn(
- name,
- value,
- () => {
- this.checkCapacity([value], 2 + value.length);
- this.write('"');
- this.writeEscaped(value, true);
- this.write('"');
- },
- "string",
- );
- return this;
- }
+ stringColumn(name: string, value: string): SenderBuffer;
/**
* Write a boolean column with its value into the buffer of the sender.
@@ -211,18 +98,7 @@ class SenderBuffer {
* @param {boolean} value - Column value, accepts only boolean values.
* @return {Sender} Returns with a reference to this sender.
*/
- booleanColumn(name: string, value: boolean): SenderBuffer {
- this.writeColumn(
- name,
- value,
- () => {
- this.checkCapacity([], 1);
- this.write(value ? "t" : "f");
- },
- "boolean",
- );
- return this;
- }
+ booleanColumn(name: string, value: boolean): SenderBuffer;
/**
* Write a float column with its value into the buffer of the sender.
@@ -231,19 +107,7 @@ class SenderBuffer {
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
*/
- floatColumn(name: string, value: number): SenderBuffer {
- this.writeColumn(
- name,
- value,
- () => {
- const valueStr = value.toString();
- this.checkCapacity([valueStr]);
- this.write(valueStr);
- },
- "number",
- );
- return this;
- }
+ floatColumn(name: string, value: number): SenderBuffer;
/**
* Write an integer column with its value into the buffer of the sender.
@@ -252,18 +116,7 @@ class SenderBuffer {
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
*/
- intColumn(name: string, value: number): SenderBuffer {
- if (!Number.isInteger(value)) {
- throw new Error(`Value must be an integer, received ${value}`);
- }
- this.writeColumn(name, value, () => {
- const valueStr = value.toString();
- this.checkCapacity([valueStr], 1);
- this.write(valueStr);
- this.write("i");
- });
- return this;
- }
+ intColumn(name: string, value: number): SenderBuffer;
/**
* Write a timestamp column with its value into the buffer of the sender.
@@ -276,20 +129,8 @@ class SenderBuffer {
timestampColumn(
name: string,
value: number | bigint,
- unit: TimestampUnit = "us",
- ): SenderBuffer {
- if (typeof value !== "bigint" && !Number.isInteger(value)) {
- throw new Error(`Value must be an integer or BigInt, received ${value}`);
- }
- this.writeColumn(name, value, () => {
- const valueMicros = timestampToMicros(BigInt(value), unit);
- const valueStr = valueMicros.toString();
- this.checkCapacity([valueStr], 1);
- this.write(valueStr);
- this.write("t");
- });
- return this;
- }
+ unit: TimestampUnit,
+ ): SenderBuffer;
/**
* Closing the row after writing the designated timestamp into the buffer of the sender.
@@ -297,135 +138,18 @@ class SenderBuffer {
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
*/
- at(timestamp: number | bigint, unit: TimestampUnit = "us") {
- if (!this.hasSymbols && !this.hasColumns) {
- throw new Error(
- "The row must have a symbol or column set before it is closed",
- );
- }
- if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
- throw new Error(
- `Designated timestamp must be an integer or BigInt, received ${timestamp}`,
- );
- }
- const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
- const timestampStr = timestampNanos.toString();
- this.checkCapacity([timestampStr], 2);
- this.write(" ");
- this.write(timestampStr);
- this.write("\n");
- this.startNewRow();
- }
+ at(timestamp: number | bigint, unit: TimestampUnit): void;
/**
* Closing the row without writing designated timestamp into the buffer of the sender.
* Designated timestamp will be populated by the server on this record.
*/
- atNow() {
- if (!this.hasSymbols && !this.hasColumns) {
- throw new Error(
- "The row must have a symbol or column set before it is closed",
- );
- }
- this.checkCapacity([], 1);
- this.write("\n");
- this.startNewRow();
- }
-
- private checkCapacity(data: string[], base = 0) {
- let length = base;
- for (const str of data) {
- length += Buffer.byteLength(str, "utf8");
- }
- if (this.position + length > this.bufferSize) {
- let newSize = this.bufferSize;
- do {
- newSize += this.bufferSize;
- } while (this.position + length > newSize);
- this.resize(newSize);
- }
- }
-
- private compact() {
- if (this.endOfLastRow > 0) {
- this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
- this.position = this.position - this.endOfLastRow;
- this.endOfLastRow = 0;
- }
- }
-
- private writeColumn(
- name: string,
- value: unknown,
- writeValue: () => void,
- valueType?: string,
- ) {
- if (typeof name !== "string") {
- throw new Error(`Column name must be a string, received ${typeof name}`);
- }
- if (valueType && typeof value !== valueType) {
- throw new Error(
- `Column value must be of type ${valueType}, received ${typeof value}`,
- );
- }
- if (!this.hasTable) {
- throw new Error("Column can be set only after table name is set");
- }
- this.checkCapacity([name], 2 + name.length);
- this.write(this.hasColumns ? "," : " ");
- validateColumnName(name, this.maxNameLength);
- this.writeEscaped(name);
- this.write("=");
- writeValue();
- this.hasColumns = true;
- }
-
- private write(data: string) {
- this.position += this.buffer.write(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
- }
-
- private writeEscaped(data: string, quoted = false) {
- for (const ch of data) {
- if (ch > "\\") {
- this.write(ch);
- continue;
- }
-
- switch (ch) {
- case " ":
- case ",":
- case "=":
- if (!quoted) {
- this.write("\\");
- }
- this.write(ch);
- break;
- case "\n":
- case "\r":
- this.write("\\");
- this.write(ch);
- break;
- case '"':
- if (quoted) {
- this.write("\\");
- }
- this.write(ch);
- break;
- case "\\":
- this.write("\\\\");
- break;
- default:
- this.write(ch);
- break;
- }
- }
- }
+ atNow(): void;
}
-export { SenderBuffer, DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE };
+export {
+ SenderBuffer,
+ createBuffer,
+ DEFAULT_BUFFER_SIZE,
+ DEFAULT_MAX_BUFFER_SIZE,
+};
diff --git a/src/options.ts b/src/options.ts
index f7ec648..1eb0b4e 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -4,6 +4,7 @@ import http from "http";
import https from "https";
import { Logger } from "./logging";
+import { fetchJson } from "./utils";
const HTTP_PORT = 9000;
const TCP_PORT = 9009;
@@ -17,6 +18,12 @@ const ON = "on";
const OFF = "off";
const UNSAFE_OFF = "unsafe_off";
+const PROTOCOL_VERSION_AUTO = "auto";
+const PROTOCOL_VERSION_V1 = "1";
+const PROTOCOL_VERSION_V2 = "2";
+
+const LINE_PROTO_SUPPORT_VERSION = "line.proto.support.versions";
+
type ExtraOptions = {
log?: Logger;
agent?: Agent | http.Agent | https.Agent;
@@ -44,6 +51,9 @@ type DeprecatedOptions = {
*
protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
* When https or tcps used, the connection is secured with TLS encryption.
*
+ *
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used to communicate with the server.
+ * When https or tcps used, the connection is secured with TLS encryption.
+ *
*
addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
* If no port is specified, a default will be used.
* When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009.
@@ -127,6 +137,8 @@ type DeprecatedOptions = {
*/
class SenderOptions {
protocol: string;
+ protocol_version?: string;
+
addr?: string;
host?: string; // derived from addr
port?: number; // derived from addr
@@ -202,6 +214,35 @@ class SenderOptions {
}
}
+ static async resolveAuto(options: SenderOptions) {
+ parseProtocolVersion(options);
+ if (options.protocol_version !== PROTOCOL_VERSION_AUTO) {
+ return options;
+ }
+
+ const url = `${options.protocol}://${options.host}:${options.port}/settings`;
+ const settings: {
+ config: { LINE_PROTO_SUPPORT_VERSION: number[] };
+ } = await fetchJson(url);
+ const supportedVersions: string[] = (
+ settings.config[LINE_PROTO_SUPPORT_VERSION] ?? []
+ ).map((version: unknown) => String(version));
+
+ if (supportedVersions.length === 0) {
+ options.protocol_version = PROTOCOL_VERSION_V1;
+ } else if (supportedVersions.includes(PROTOCOL_VERSION_V2)) {
+ options.protocol_version = PROTOCOL_VERSION_V2;
+ } else if (supportedVersions.includes(PROTOCOL_VERSION_V1)) {
+ options.protocol_version = PROTOCOL_VERSION_V1;
+ } else {
+ throw new Error(
+ "Unsupported protocol versions received from server: " +
+ supportedVersions,
+ );
+ }
+ return options;
+ }
+
static resolveDeprecated(
options: SenderOptions & DeprecatedOptions,
log: Logger,
@@ -247,11 +288,13 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
*/
- static fromConfig(
+ static async fromConfig(
configurationString: string,
extraOptions?: ExtraOptions,
- ): SenderOptions {
- return new SenderOptions(configurationString, extraOptions);
+ ): Promise {
+ const options = new SenderOptions(configurationString, extraOptions);
+ await SenderOptions.resolveAuto(options);
+ return options;
}
/**
@@ -265,8 +308,11 @@ class SenderOptions {
*
* @return {SenderOptions} A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.
*/
- static fromEnv(extraOptions?: ExtraOptions): SenderOptions {
- return SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions);
+ static async fromEnv(extraOptions?: ExtraOptions): Promise {
+ return await SenderOptions.fromConfig(
+ process.env.QDB_CLIENT_CONF,
+ extraOptions,
+ );
}
}
@@ -280,6 +326,7 @@ function parseConfigurationString(
const position = parseProtocol(options, configString);
parseSettings(options, configString, position);
+ parseProtocolVersion(options);
parseAddress(options);
parseBufferSizes(options);
parseAutoFlushOptions(options);
@@ -333,6 +380,7 @@ function parseSetting(
}
const ValidConfigKeys = [
+ "protocol_version",
"addr",
"username",
"password",
@@ -398,6 +446,30 @@ function parseProtocol(options: SenderOptions, configString: string) {
return index + 2;
}
+function parseProtocolVersion(options: SenderOptions) {
+ const protocol_version = options.protocol_version ?? PROTOCOL_VERSION_AUTO;
+ switch (protocol_version) {
+ case PROTOCOL_VERSION_AUTO:
+ switch (options.protocol) {
+ case HTTP:
+ case HTTPS:
+ options.protocol_version = PROTOCOL_VERSION_AUTO;
+ break;
+ default:
+ options.protocol_version = PROTOCOL_VERSION_V1;
+ }
+ break;
+ case PROTOCOL_VERSION_V1:
+ case PROTOCOL_VERSION_V2:
+ break;
+ default:
+ throw new Error(
+ `Invalid protocol version: '${protocol_version}', accepted values: 'auto', '1', '2'`,
+ );
+ }
+ return;
+}
+
function parseAddress(options: SenderOptions) {
if (!options.addr) {
throw new Error("Invalid configuration, 'addr' is required");
@@ -520,4 +592,14 @@ function parseInteger(
}
}
-export { SenderOptions, ExtraOptions, HTTP, HTTPS, TCP, TCPS };
+export {
+ SenderOptions,
+ ExtraOptions,
+ HTTP,
+ HTTPS,
+ TCP,
+ TCPS,
+ PROTOCOL_VERSION_AUTO,
+ PROTOCOL_VERSION_V1,
+ PROTOCOL_VERSION_V2,
+};
diff --git a/src/sender.ts b/src/sender.ts
index 62db289..e06df2e 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -2,8 +2,8 @@
import { log, Logger } from "./logging";
import { SenderOptions, ExtraOptions } from "./options";
import { SenderTransport, createTransport } from "./transport";
+import { SenderBuffer, createBuffer } from "./buffer";
import { isBoolean, isInteger, TimestampUnit } from "./utils";
-import { SenderBuffer } from "./buffer";
const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
@@ -72,7 +72,7 @@ class Sender {
*/
constructor(options: SenderOptions) {
this.transport = createTransport(options);
- this.buffer = new SenderBuffer(options);
+ this.buffer = createBuffer(options);
this.log = typeof options.log === "function" ? options.log : log;
@@ -99,12 +99,12 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the provided configuration string.
*/
- static fromConfig(
+ static async fromConfig(
configurationString: string,
extraOptions?: ExtraOptions,
- ): Sender {
+ ): Promise {
return new Sender(
- SenderOptions.fromConfig(configurationString, extraOptions),
+ await SenderOptions.fromConfig(configurationString, extraOptions),
);
}
@@ -119,9 +119,9 @@ class Sender {
*
* @return {Sender} A Sender object initialized from the QDB_CLIENT_CONF environment variable.
*/
- static fromEnv(extraOptions?: ExtraOptions): Sender {
+ static async fromEnv(extraOptions?: ExtraOptions): Promise {
return new Sender(
- SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions),
+ await SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions),
);
}
diff --git a/src/utils.ts b/src/utils.ts
index e4e67fa..734740d 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -36,10 +36,27 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
}
}
+async function fetchJson(url: string): Promise {
+ let response: globalThis.Response;
+ try {
+ response = await fetch(url);
+ } catch (error) {
+ throw new Error(`Failed to load ${url} [error=${error}]`);
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to load ${url} [statusCode=${response.status} (${response.statusText})]`,
+ );
+ }
+ return (await response.json()) as T;
+}
+
export {
isBoolean,
isInteger,
timestampToMicros,
timestampToNanos,
TimestampUnit,
+ fetchJson,
};
diff --git a/test/options.test.ts b/test/options.test.ts
index e8be4e5..bfbdf3e 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -1,15 +1,42 @@
// @ts-check
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Agent } from "undici";
import { SenderOptions } from "../src/options";
+import { MockHttp } from "./util/mockhttp";
+import { readFileSync } from "fs";
+
+const MOCK_HTTP_PORT = 9097;
+const MOCK_HTTPS_PORT = 9096;
+
+const proxyOptions = {
+ key: readFileSync("test/certs/server/server.key"),
+ cert: readFileSync("test/certs/server/server.crt"),
+ ca: readFileSync("test/certs/ca/ca.crt"),
+};
+
+process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
describe("Configuration string parser suite", function () {
- it("can parse a basic config string", function () {
- const options = SenderOptions.fromConfig(
- "https::addr=host;username=user1;password=pwd;",
+ const mockHttp = new MockHttp();
+ const mockHttps = new MockHttp();
+
+ beforeAll(async function () {
+ await mockHttp.start(MOCK_HTTP_PORT);
+ await mockHttps.start(MOCK_HTTPS_PORT, true, proxyOptions);
+ });
+
+ afterAll(async function () {
+ await mockHttp.stop();
+ await mockHttps.stop();
+ }, 30000);
+
+ it("can parse a basic config string", async function () {
+ const options = await SenderOptions.fromConfig(
+ "https::addr=host;username=user1;password=pwd;protocol_version=2",
);
expect(options.protocol).toBe("https");
+ expect(options.protocol_version).toBe("2");
expect(options.addr).toBe("host");
expect(options.username).toBe("user1");
expect(options.password).toBe("pwd");
@@ -17,47 +44,63 @@ describe("Configuration string parser suite", function () {
it("can parse a config string from environment variable", async function () {
process.env.QDB_CLIENT_CONF = "tcp::addr=host;";
- const options = SenderOptions.fromEnv();
+ const options = await SenderOptions.fromEnv();
expect(options.protocol).toBe("tcp");
expect(options.addr).toBe("host");
});
- it("accepts only lowercase protocols", function () {
- let options = SenderOptions.fromConfig("tcp::addr=host;");
+ it("accepts only lowercase protocols", async function () {
+ let options = await SenderOptions.fromConfig("tcp::addr=host;");
expect(options.protocol).toBe("tcp");
- options = SenderOptions.fromConfig("tcps::addr=host;");
+ options = await SenderOptions.fromConfig("tcps::addr=host;");
expect(options.protocol).toBe("tcps");
- options = SenderOptions.fromConfig("http::addr=host;");
+ options = await SenderOptions.fromConfig(
+ "http::addr=host;protocol_version=2",
+ );
expect(options.protocol).toBe("http");
- options = SenderOptions.fromConfig("https::addr=host;");
+ options = await SenderOptions.fromConfig(
+ "https::addr=host;protocol_version=2",
+ );
expect(options.protocol).toBe("https");
- expect(() => SenderOptions.fromConfig("HTTP::")).toThrow(
+ await expect(
+ async () => await SenderOptions.fromConfig("HTTP::"),
+ ).rejects.toThrow(
"Invalid protocol: 'HTTP', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
- expect(() => SenderOptions.fromConfig("Http::")).toThrow(
+ await expect(
+ async () => await SenderOptions.fromConfig("Http::"),
+ ).rejects.toThrow(
"Invalid protocol: 'Http', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
- expect(() => SenderOptions.fromConfig("HtTps::")).toThrow(
+ await expect(
+ async () => await SenderOptions.fromConfig("HtTps::"),
+ ).rejects.toThrow(
"Invalid protocol: 'HtTps', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
- expect(() => SenderOptions.fromConfig("TCP::")).toThrow(
+ await expect(
+ async () => await SenderOptions.fromConfig("TCP::"),
+ ).rejects.toThrow(
"Invalid protocol: 'TCP', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
- expect(() => SenderOptions.fromConfig("TcP::")).toThrow(
+ await expect(
+ async () => await SenderOptions.fromConfig("TcP::"),
+ ).rejects.toThrow(
"Invalid protocol: 'TcP', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
- expect(() => SenderOptions.fromConfig("Tcps::")).toThrow(
+ await expect(
+ async () => await SenderOptions.fromConfig("Tcps::"),
+ ).rejects.toThrow(
"Invalid protocol: 'Tcps', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
});
- it("considers that keys and values are case-sensitive", function () {
- const options = SenderOptions.fromConfig(
+ it("considers that keys and values are case-sensitive", async function () {
+ const options = await SenderOptions.fromConfig(
"tcps::addr=Host;username=useR1;token=TOKEN;",
);
expect(options.protocol).toBe("tcps");
@@ -65,47 +108,63 @@ describe("Configuration string parser suite", function () {
expect(options.username).toBe("useR1");
expect(options.token).toBe("TOKEN");
- expect(() =>
- SenderOptions.fromConfig("tcps::addr=Host;UserNAME=useR1;PaSswOrD=pWd;"),
- ).toThrow("Unknown configuration key: 'UserNAME'");
- expect(() =>
- SenderOptions.fromConfig("tcps::addr=Host;PaSswOrD=pWd;"),
- ).toThrow("Unknown configuration key: 'PaSswOrD'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "tcps::addr=Host;UserNAME=useR1;PaSswOrD=pWd;",
+ ),
+ ).rejects.toThrow("Unknown configuration key: 'UserNAME'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcps::addr=Host;PaSswOrD=pWd;"),
+ ).rejects.toThrow("Unknown configuration key: 'PaSswOrD'");
});
- it("can parse with or without the last semicolon", function () {
- let options = SenderOptions.fromConfig("https::addr=host:9002");
+ it("can parse with or without the last semicolon", async function () {
+ let options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;protocol_version=2;",
+ );
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
- options = SenderOptions.fromConfig("https::addr=host:9002;");
+ options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;protocol_version=2",
+ );
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
- options = SenderOptions.fromConfig("https::addr=host:9002;token=abcde");
+ options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;token=abcde;protocol_version=2",
+ );
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
expect(options.token).toBe("abcde");
- options = SenderOptions.fromConfig("https::addr=host:9002;token=abcde;");
+ options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;token=abcde;protocol_version=2;",
+ );
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
expect(options.token).toBe("abcde");
- options = SenderOptions.fromConfig("https::addr=host:9002;token=abcde;;");
+ options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;protocol_version=2;token=abcde;;",
+ );
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
expect(options.token).toBe("abcde;");
- options = SenderOptions.fromConfig("https::addr=host:9002;token=abcde;;;");
+ options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;protocol_version=2;token=abcde;;;",
+ );
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
expect(options.token).toBe("abcde;");
});
- it("can parse escaped config string values", function () {
- const options = SenderOptions.fromConfig(
- "https::addr=host:9002;username=us;;;;;;er;;1;;;password=p;;wd;",
+ it("can parse escaped config string values", async function () {
+ const options = await SenderOptions.fromConfig(
+ "https::addr=host:9002;protocol_version=2;username=us;;;;;;er;;1;;;password=p;;wd;",
);
expect(options.protocol).toBe("https");
expect(options.addr).toBe("host:9002");
@@ -113,17 +172,18 @@ describe("Configuration string parser suite", function () {
expect(options.password).toBe("p;wd");
});
- it("can parse the address", function () {
- let options = SenderOptions.fromConfig(
- "https::addr=host1:9002;token=resttoken123;",
+ it("can parse the address", async function () {
+ let options = await SenderOptions.fromConfig(
+ "https::addr=host1:9002;token=resttoken123;protocol_version=2;",
);
expect(options.protocol).toBe("https");
+ expect(options.protocol_version).toBe("2");
expect(options.addr).toBe("host1:9002");
expect(options.host).toBe("host1");
expect(options.port).toBe(9002);
expect(options.token).toBe("resttoken123");
- options = SenderOptions.fromConfig(
+ options = await SenderOptions.fromConfig(
"tcps::addr=host2:9005;username=user1;token=jwkprivkey123;",
);
expect(options.protocol).toBe("tcps");
@@ -134,9 +194,9 @@ describe("Configuration string parser suite", function () {
expect(options.token).toBe("jwkprivkey123");
});
- it("can default the port", function () {
- let options = SenderOptions.fromConfig(
- "https::addr=hostname;token=resttoken123;",
+ it("can default the port", async function () {
+ let options = await SenderOptions.fromConfig(
+ "https::addr=hostname;protocol_version=2;token=resttoken123;",
);
expect(options.protocol).toBe("https");
expect(options.addr).toBe("hostname");
@@ -144,8 +204,8 @@ describe("Configuration string parser suite", function () {
expect(options.port).toBe(9000);
expect(options.token).toBe("resttoken123");
- options = SenderOptions.fromConfig(
- "http::addr=hostname;token=resttoken123;",
+ options = await SenderOptions.fromConfig(
+ "http::addr=hostname;protocol_version=2;token=resttoken123;",
);
expect(options.protocol).toBe("http");
expect(options.addr).toBe("hostname");
@@ -153,7 +213,7 @@ describe("Configuration string parser suite", function () {
expect(options.port).toBe(9000);
expect(options.token).toBe("resttoken123");
- options = SenderOptions.fromConfig(
+ options = await SenderOptions.fromConfig(
"tcps::addr=hostname;username=user1;token=jwkprivkey123;",
);
expect(options.protocol).toBe("tcps");
@@ -163,7 +223,7 @@ describe("Configuration string parser suite", function () {
expect(options.username).toBe("user1");
expect(options.token).toBe("jwkprivkey123");
- options = SenderOptions.fromConfig(
+ options = await SenderOptions.fromConfig(
"tcp::addr=hostname;username=user1;token=jwkprivkey123;",
);
expect(options.protocol).toBe("tcp");
@@ -174,89 +234,207 @@ describe("Configuration string parser suite", function () {
expect(options.token).toBe("jwkprivkey123");
});
- it("fails if port is not a positive integer", function () {
- expect(() => SenderOptions.fromConfig("tcp::addr=host:;")).toThrow(
- "Port is required",
+ it("can parse protocol version", async function () {
+ // invalid protocol version
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=hostname;protocol_version=3"),
+ ).rejects.toThrow(
+ "Invalid protocol version: '3', accepted values: 'auto', '1', '2'",
);
- expect(() => SenderOptions.fromConfig("tcp::addr=host:0")).toThrow(
- "Invalid port: 0",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=hostname;protocol_version=0",
+ ),
+ ).rejects.toThrow(
+ "Invalid protocol version: '0', accepted values: 'auto', '1', '2'",
);
- expect(() => SenderOptions.fromConfig("tcp::addr=host:0.2")).toThrow(
- "Invalid port: 0.2",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=hostname;protocol_version=-1",
+ ),
+ ).rejects.toThrow(
+ "Invalid protocol version: '-1', accepted values: 'auto', '1', '2'",
);
- expect(() => SenderOptions.fromConfig("tcp::addr=host:-2")).toThrow(
- "Invalid port: -2",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "https::addr=hostname;protocol_version=automatic",
+ ),
+ ).rejects.toThrow(
+ "Invalid protocol version: 'automatic', accepted values: 'auto', '1', '2'",
);
- expect(() => SenderOptions.fromConfig("tcp::addr=host:!;")).toThrow(
- "Invalid port: '!'",
+
+ // defaults
+ let options = await SenderOptions.fromConfig("tcp::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig("tcps::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT}`,
);
- expect(() => SenderOptions.fromConfig("tcp::addr=host:9009x;")).toThrow(
- "Invalid port: '9009x'",
+ expect(options.protocol_version).toBe("2");
+ options = await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT}`,
);
- expect(() => SenderOptions.fromConfig("tcp::addr=host:900 9;")).toThrow(
- "Invalid port: '900 9'",
+ expect(options.protocol_version).toBe("2");
+
+ // auto, 1, 2 with each protocol (tcp, tcps, http, https)
+ options = await SenderOptions.fromConfig(
+ "tcp::addr=localhost;protocol_version=1",
);
- });
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ "tcp::addr=localhost;protocol_version=2",
+ );
+ expect(options.protocol_version).toBe("2");
+ options = await SenderOptions.fromConfig(
+ "tcp::addr=localhost;protocol_version=auto",
+ );
+ expect(options.protocol_version).toBe("1");
+
+ options = await SenderOptions.fromConfig(
+ "tcps::addr=localhost;protocol_version=1",
+ );
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ "tcps::addr=localhost;protocol_version=2",
+ );
+ expect(options.protocol_version).toBe("2");
+ options = await SenderOptions.fromConfig(
+ "tcps::addr=localhost;protocol_version=auto",
+ );
+ expect(options.protocol_version).toBe("1");
+
+ options = await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT};protocol_version=1`,
+ );
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT};protocol_version=2`,
+ );
+ expect(options.protocol_version).toBe("2");
+ options = await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT};protocol_version=auto`,
+ );
+ expect(options.protocol_version).toBe("2");
- it("fails if init_buf_size is not a positive integer", function () {
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;init_buf_size=;"),
- ).toThrow("Invalid configuration, value is not set for 'init_buf_size'");
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;init_buf_size=1024a;"),
- ).toThrow("Invalid initial buffer size option, not a number: '1024a'");
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;init_buf_size=102 4;"),
- ).toThrow("Invalid initial buffer size option, not a number: '102 4'");
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;init_buf_size=0;"),
- ).toThrow("Invalid initial buffer size option: 0");
+ options = await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT};protocol_version=1`,
+ );
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT};protocol_version=2`,
+ );
+ expect(options.protocol_version).toBe("2");
+ options = await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT};protocol_version=auto`,
+ );
+ expect(options.protocol_version).toBe("2");
});
- it("fails if max_buf_size is not a positive integer", function () {
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;max_buf_size=;"),
- ).toThrow("Invalid configuration, value is not set for 'max_buf_size'");
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;max_buf_size=1024a;"),
- ).toThrow("Invalid max buffer size option, not a number: '1024a'");
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;max_buf_size=102 4;"),
- ).toThrow("Invalid max buffer size option, not a number: '102 4'");
- expect(() =>
- SenderOptions.fromConfig("tcp::addr=host;max_buf_size=0;"),
- ).toThrow("Invalid max buffer size option: 0");
+ it("fails if port is not a positive integer", async function () {
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:;"),
+ ).rejects.toThrow("Port is required");
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:0"),
+ ).rejects.toThrow("Invalid port: 0");
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:0.2"),
+ ).rejects.toThrow("Invalid port: 0.2");
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:-2"),
+ ).rejects.toThrow("Invalid port: -2");
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:!;"),
+ ).rejects.toThrow("Invalid port: '!'");
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:9009x;"),
+ ).rejects.toThrow("Invalid port: '9009x'");
+ await expect(
+ async () => await SenderOptions.fromConfig("tcp::addr=host:900 9;"),
+ ).rejects.toThrow("Invalid port: '900 9'");
});
- it("rejects missing or empty hostname", function () {
- expect(() => SenderOptions.fromConfig("http::")).toThrow(
- "Invalid configuration, 'addr' is required",
+ it("fails if init_buf_size is not a positive integer", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;init_buf_size=;"),
+ ).rejects.toThrow(
+ "Invalid configuration, value is not set for 'init_buf_size'",
);
- expect(() => SenderOptions.fromConfig("http::;")).toThrow(
- "Missing '=' sign in ''",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;init_buf_size=1024a;"),
+ ).rejects.toThrow(
+ "Invalid initial buffer size option, not a number: '1024a'",
);
- expect(() => SenderOptions.fromConfig("http::addr=;")).toThrow(
- "Invalid configuration, value is not set for 'addr'",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;init_buf_size=102 4;"),
+ ).rejects.toThrow(
+ "Invalid initial buffer size option, not a number: '102 4'",
);
- expect(() =>
- SenderOptions.fromConfig("http::addr=;username=user1;"),
- ).toThrow("Invalid configuration, value is not set for 'addr'");
- expect(() =>
- SenderOptions.fromConfig("http::username=user1;addr=;"),
- ).toThrow("Invalid configuration, value is not set for 'addr'");
- expect(() => SenderOptions.fromConfig("http::addr=:9000;")).toThrow(
- "Host name is required",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;init_buf_size=0;"),
+ ).rejects.toThrow("Invalid initial buffer size option: 0");
+ });
+
+ it("fails if max_buf_size is not a positive integer", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;max_buf_size=;"),
+ ).rejects.toThrow(
+ "Invalid configuration, value is not set for 'max_buf_size'",
);
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;max_buf_size=1024a;"),
+ ).rejects.toThrow("Invalid max buffer size option, not a number: '1024a'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;max_buf_size=102 4;"),
+ ).rejects.toThrow("Invalid max buffer size option, not a number: '102 4'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("tcp::addr=host;max_buf_size=0;"),
+ ).rejects.toThrow("Invalid max buffer size option: 0");
+ });
- const options = SenderOptions.fromConfig("http::addr=x;");
+ it("rejects missing or empty hostname", async function () {
+ await expect(
+ async () => await SenderOptions.fromConfig("http::"),
+ ).rejects.toThrow("Invalid configuration, 'addr' is required");
+ await expect(
+ async () => await SenderOptions.fromConfig("http::;"),
+ ).rejects.toThrow("Missing '=' sign in ''");
+ await expect(
+ async () => await SenderOptions.fromConfig("http::addr=;"),
+ ).rejects.toThrow("Invalid configuration, value is not set for 'addr'");
+ await expect(
+ async () => await SenderOptions.fromConfig("http::addr=;username=user1;"),
+ ).rejects.toThrow("Invalid configuration, value is not set for 'addr'");
+ await expect(
+ async () => await SenderOptions.fromConfig("http::username=user1;addr=;"),
+ ).rejects.toThrow("Invalid configuration, value is not set for 'addr'");
+ await expect(
+ async () => await SenderOptions.fromConfig("http::addr=:9000;"),
+ ).rejects.toThrow("Host name is required");
+
+ const options = await SenderOptions.fromConfig(
+ "http::addr=x;protocol_version=2",
+ );
expect(options.protocol).toBe("http");
expect(options.host).toBe("x");
- expect(options.host).toBe("x");
});
- it("does not default optional fields", function () {
- const options = SenderOptions.fromConfig(
- "https::addr=host:9000;token=abcdef123;",
+ it("does not default optional fields", async function () {
+ const options = await SenderOptions.fromConfig(
+ "https::addr=host:9000;token=abcdef123;protocol_version=2;",
);
expect(options.protocol).toBe("https");
expect(options.token).toBe("abcdef123");
@@ -264,440 +442,657 @@ describe("Configuration string parser suite", function () {
expect(options.password).toBe(undefined);
});
- it("rejects invalid config value", function () {
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username=;"),
- ).toThrow("Invalid configuration, value is not set for 'username'");
+ it("rejects invalid config value", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("http::addr=host:9000;username=;"),
+ ).rejects.toThrow("Invalid configuration, value is not set for 'username'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username=user\t;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("http::addr=host:9000;username=user\t;"),
+ ).rejects.toThrow(
"Invalid configuration, control characters are not allowed: 'user\t'",
);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username=user\n;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("http::addr=host:9000;username=user\n;"),
+ ).rejects.toThrow(
"Invalid configuration, control characters are not allowed: 'user\n'",
);
- let options = SenderOptions.fromConfig(
- "http::addr=host:9000;username=us\x7Eer;",
+ let options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;username=us\x7Eer;protocol_version=2;",
);
expect(options.protocol).toBe("http");
expect(options.addr).toBe("host:9000");
expect(options.username).toBe("us\x7Eer");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username=us\x7Fer;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;username=us\x7Fer;",
+ ),
+ ).rejects.toThrow(
"Invalid configuration, control characters are not allowed: 'us\x7Fer'",
);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username=us\x9Fer;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;username=us\x9Fer;",
+ ),
+ ).rejects.toThrow(
"Invalid configuration, control characters are not allowed: 'us\x9Fer'",
);
- options = SenderOptions.fromConfig(
- "http::addr=host:9000;username=us\xA0er;",
+ options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;username=us\xA0er;protocol_version=2;",
);
expect(options.protocol).toBe("http");
expect(options.addr).toBe("host:9000");
expect(options.username).toBe("us\xA0er");
});
- it("reject invalid config keys", function () {
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username=user1;pass=pwd;"),
- ).toThrow("Unknown configuration key: 'pass'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;user=user1;password=pwd;"),
- ).toThrow("Unknown configuration key: 'user'");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;username =user1;password=pwd;",
- ),
- ).toThrow("Unknown configuration key: 'username '");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000; username=user1;password=pwd;",
- ),
- ).toThrow("Unknown configuration key: ' username'");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;user name=user1;password=pwd;",
- ),
- ).toThrow("Unknown configuration key: 'user name'");
- });
-
- it("rejects keys without value", function () {
- expect(() => SenderOptions.fromConfig("http::addr;username=user1")).toThrow(
- "Missing '=' sign in 'addr'",
- );
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;username;"),
- ).toThrow("Missing '=' sign in 'username'");
- });
-
- it("throws error if protocol is invalid", function () {
- expect(() =>
- SenderOptions.fromConfig("::addr=host;username=user1;password=pwd;"),
- ).toThrow(
+ it("reject invalid config keys", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;username=user1;pass=pwd;",
+ ),
+ ).rejects.toThrow("Unknown configuration key: 'pass'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;user=user1;password=pwd;",
+ ),
+ ).rejects.toThrow("Unknown configuration key: 'user'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;username =user1;password=pwd;",
+ ),
+ ).rejects.toThrow("Unknown configuration key: 'username '");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000; username=user1;password=pwd;",
+ ),
+ ).rejects.toThrow("Unknown configuration key: ' username'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;user name=user1;password=pwd;",
+ ),
+ ).rejects.toThrow("Unknown configuration key: 'user name'");
+ });
+
+ it("rejects keys without value", async function () {
+ await expect(
+ async () => await SenderOptions.fromConfig("http::addr;username=user1"),
+ ).rejects.toThrow("Missing '=' sign in 'addr'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig("http::addr=host:9000;username;"),
+ ).rejects.toThrow("Missing '=' sign in 'username'");
+ });
+
+ it("throws error if protocol is invalid", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "::addr=host;username=user1;password=pwd;",
+ ),
+ ).rejects.toThrow(
"Invalid protocol: '', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
- expect(() =>
- SenderOptions.fromConfig("htt::addr=host;username=user1;password=pwd;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "htt::addr=host;username=user1;password=pwd;",
+ ),
+ ).rejects.toThrow(
"Invalid protocol: 'htt', accepted protocols: 'http', 'https', 'tcp', 'tcps'",
);
});
- it("throws error if protocol is missing", function () {
- expect(() =>
- SenderOptions.fromConfig("addr=host;username=user1;password=pwd;"),
- ).toThrow(
+ it("throws error if protocol is missing", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "addr=host;username=user1;password=pwd;",
+ ),
+ ).rejects.toThrow(
"Missing protocol, configuration string format: 'protocol::key1=value1;key2=value2;key3=value3;'",
);
- expect(() =>
- SenderOptions.fromConfig("https:addr=host;username=user1;password=pwd;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "https:addr=host;username=user1;password=pwd;",
+ ),
+ ).rejects.toThrow(
"Missing protocol, configuration string format: 'protocol::key1=value1;key2=value2;key3=value3;'",
);
- expect(() =>
- SenderOptions.fromConfig("https addr=host;username=user1;password=pwd;"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "https addr=host;username=user1;password=pwd;",
+ ),
+ ).rejects.toThrow(
"Missing protocol, configuration string format: 'protocol::key1=value1;key2=value2;key3=value3;'",
);
});
- it("throws error if configuration string is missing", function () {
+ it("throws error if configuration string is missing", async function () {
// @ts-expect-error - Testing invalid input
- expect(() => SenderOptions.fromConfig()).toThrow(
- "Configuration string is missing",
- );
- expect(() => SenderOptions.fromConfig("")).toThrow(
- "Configuration string is missing",
- );
- expect(() => SenderOptions.fromConfig(null)).toThrow(
- "Configuration string is missing",
- );
- expect(() => SenderOptions.fromConfig(undefined)).toThrow(
+ await expect(async () => await SenderOptions.fromConfig()).rejects.toThrow(
"Configuration string is missing",
);
+ await expect(
+ async () => await SenderOptions.fromConfig(""),
+ ).rejects.toThrow("Configuration string is missing");
+ await expect(
+ async () => await SenderOptions.fromConfig(null),
+ ).rejects.toThrow("Configuration string is missing");
+ await expect(
+ async () => await SenderOptions.fromConfig(undefined),
+ ).rejects.toThrow("Configuration string is missing");
});
- it("can parse auto_flush config", function () {
- let options = SenderOptions.fromConfig(
- "http::addr=host:9000;auto_flush=on;",
+ it("can parse auto_flush config", async function () {
+ let options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=on;",
);
expect(options.protocol).toBe("http");
expect(options.host).toBe("host");
expect(options.port).toBe(9000);
expect(options.auto_flush).toBe(true);
- options = SenderOptions.fromConfig("http::addr=host:9000;auto_flush=off;");
+ options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=off;",
+ );
expect(options.protocol).toBe("http");
expect(options.host).toBe("host");
expect(options.port).toBe(9000);
expect(options.auto_flush).toBe(false);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush=ON;"),
- ).toThrow("Invalid auto flush option: 'ON'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush=On;"),
- ).toThrow("Invalid auto flush option: 'On'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush=true;"),
- ).toThrow("Invalid auto flush option: 'true'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush=OFF;"),
- ).toThrow("Invalid auto flush option: 'OFF'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush=Off;"),
- ).toThrow("Invalid auto flush option: 'Off'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush=false;"),
- ).toThrow("Invalid auto flush option: 'false'");
- });
-
- it("can parse auto_flush_rows config", function () {
- let options = SenderOptions.fromConfig(
- "http::addr=host:9000;auto_flush_rows=123;",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=ON;",
+ ),
+ ).rejects.toThrow("Invalid auto flush option: 'ON'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=On;",
+ ),
+ ).rejects.toThrow("Invalid auto flush option: 'On'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=true;",
+ ),
+ ).rejects.toThrow("Invalid auto flush option: 'true'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=OFF;",
+ ),
+ ).rejects.toThrow("Invalid auto flush option: 'OFF'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=Off;",
+ ),
+ ).rejects.toThrow("Invalid auto flush option: 'Off'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush=false;",
+ ),
+ ).rejects.toThrow("Invalid auto flush option: 'false'");
+ });
+
+ it("can parse auto_flush_rows config", async function () {
+ let options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=123;",
);
expect(options.protocol).toBe("http");
expect(options.auto_flush_rows).toBe(123);
- options = SenderOptions.fromConfig(
- "http::addr=host:9000;auto_flush_rows=0;",
+ options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=0;",
);
expect(options.protocol).toBe("http");
expect(options.auto_flush_rows).toBe(0);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_rows=-123;"),
- ).toThrow("Invalid auto flush rows option: -123");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_rows=1.23;"),
- ).toThrow("Invalid auto flush rows option: 1.23");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_rows=123x;"),
- ).toThrow("Invalid auto flush rows option, not a number: '123x'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_rows=a123;"),
- ).toThrow("Invalid auto flush rows option, not a number: 'a123'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_rows=1w23;"),
- ).toThrow("Invalid auto flush rows option, not a number: '1w23'");
- });
-
- it("can parse auto_flush_interval config", function () {
- let options = SenderOptions.fromConfig(
- "http::addr=host:9000;auto_flush_interval=30",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=-123;",
+ ),
+ ).rejects.toThrow("Invalid auto flush rows option: -123");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=1.23;",
+ ),
+ ).rejects.toThrow("Invalid auto flush rows option: 1.23");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=123x;",
+ ),
+ ).rejects.toThrow("Invalid auto flush rows option, not a number: '123x'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=a123;",
+ ),
+ ).rejects.toThrow("Invalid auto flush rows option, not a number: 'a123'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_rows=1w23;",
+ ),
+ ).rejects.toThrow("Invalid auto flush rows option, not a number: '1w23'");
+ });
+
+ it("can parse auto_flush_interval config", async function () {
+ let options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=30",
);
expect(options.protocol).toBe("http");
expect(options.auto_flush_interval).toBe(30);
- options = SenderOptions.fromConfig(
- "http::addr=host:9000;auto_flush_interval=0",
+ options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=0",
);
expect(options.protocol).toBe("http");
expect(options.auto_flush_interval).toBe(0);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_interval=-60"),
- ).toThrow("Invalid auto flush interval option: -60");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_interval=-6.0"),
- ).toThrow("Invalid auto flush interval option: -6");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_interval=60x"),
- ).toThrow("Invalid auto flush interval option, not a number: '60x'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_interval=a60"),
- ).toThrow("Invalid auto flush interval option, not a number: 'a60'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;auto_flush_interval=6w0"),
- ).toThrow("Invalid auto flush interval option, not a number: '6w0'");
- });
-
- it("can parse tls_verify config", function () {
- let options = SenderOptions.fromConfig(
- "http::addr=host:9000;tls_verify=on",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=-60",
+ ),
+ ).rejects.toThrow("Invalid auto flush interval option: -60");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=-6.0",
+ ),
+ ).rejects.toThrow("Invalid auto flush interval option: -6");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=60x",
+ ),
+ ).rejects.toThrow(
+ "Invalid auto flush interval option, not a number: '60x'",
+ );
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=a60",
+ ),
+ ).rejects.toThrow(
+ "Invalid auto flush interval option, not a number: 'a60'",
+ );
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;auto_flush_interval=6w0",
+ ),
+ ).rejects.toThrow(
+ "Invalid auto flush interval option, not a number: '6w0'",
+ );
+ });
+
+ it("can parse tls_verify config", async function () {
+ let options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=on",
);
expect(options.protocol).toBe("http");
expect(options.host).toBe("host");
expect(options.port).toBe(9000);
expect(options.tls_verify).toBe(true);
- options = SenderOptions.fromConfig(
- "http::addr=host:9000;tls_verify=unsafe_off",
+ options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=unsafe_off",
);
expect(options.protocol).toBe("http");
expect(options.host).toBe("host");
expect(options.port).toBe(9000);
expect(options.tls_verify).toBe(false);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=ON"),
- ).toThrow("Invalid TLS verify option: 'ON'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=On"),
- ).toThrow("Invalid TLS verify option: 'On'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=true"),
- ).toThrow("Invalid TLS verify option: 'true'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=OFF"),
- ).toThrow("Invalid TLS verify option: 'OFF'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=Off"),
- ).toThrow("Invalid TLS verify option: 'Off'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=UNSAFE_OFF"),
- ).toThrow("Invalid TLS verify option: 'UNSAFE_OFF'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=Unsafe_Off"),
- ).toThrow("Invalid TLS verify option: 'Unsafe_Off'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_verify=false"),
- ).toThrow("Invalid TLS verify option: 'false'");
- });
-
- it("fails with tls_roots or tls_roots_password config", function () {
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_roots=/whatever/path"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=ON",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'ON'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=On",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'On'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=true",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'true'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=OFF",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'OFF'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=Off",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'Off'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=UNSAFE_OFF",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'UNSAFE_OFF'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=Unsafe_Off",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'Unsafe_Off'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_verify=false",
+ ),
+ ).rejects.toThrow("Invalid TLS verify option: 'false'");
+ });
+
+ it("fails with tls_roots or tls_roots_password config", async function () {
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_roots=/whatever/path",
+ ),
+ ).rejects.toThrow(
"'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;tls_roots_password=pwd"),
- ).toThrow(
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;tls_roots_password=pwd",
+ ),
+ ).rejects.toThrow(
"'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
});
- it("can parse request_min_throughput config", function () {
- const options = SenderOptions.fromConfig(
- "http::addr=host:9000;request_min_throughput=300",
+ it("can parse request_min_throughput config", async function () {
+ const options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=300",
);
expect(options.protocol).toBe("http");
expect(options.request_min_throughput).toBe(300);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_min_throughput=0"),
- ).toThrow("Invalid request min throughput option: 0");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;request_min_throughput=0.5",
- ),
- ).toThrow("Invalid request min throughput option: 0.5");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;request_min_throughput=-60",
- ),
- ).toThrow("Invalid request min throughput option: -60");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;request_min_throughput=60x",
- ),
- ).toThrow("Invalid request min throughput option, not a number: '60x'");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;request_min_throughput=a60",
- ),
- ).toThrow("Invalid request min throughput option, not a number: 'a60'");
- expect(() =>
- SenderOptions.fromConfig(
- "http::addr=host:9000;request_min_throughput=6w0",
- ),
- ).toThrow("Invalid request min throughput option, not a number: '6w0'");
- });
-
- it("can parse request_timeout config", function () {
- const options = SenderOptions.fromConfig(
- "http::addr=host:9000;request_timeout=30",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=0",
+ ),
+ ).rejects.toThrow("Invalid request min throughput option: 0");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=0.5",
+ ),
+ ).rejects.toThrow("Invalid request min throughput option: 0.5");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=-60",
+ ),
+ ).rejects.toThrow("Invalid request min throughput option: -60");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=60x",
+ ),
+ ).rejects.toThrow(
+ "Invalid request min throughput option, not a number: '60x'",
+ );
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=a60",
+ ),
+ ).rejects.toThrow(
+ "Invalid request min throughput option, not a number: 'a60'",
+ );
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_min_throughput=6w0",
+ ),
+ ).rejects.toThrow(
+ "Invalid request min throughput option, not a number: '6w0'",
+ );
+ });
+
+ it("can parse request_timeout config", async function () {
+ const options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=30",
);
expect(options.protocol).toBe("http");
expect(options.request_timeout).toBe(30);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_timeout=0"),
- ).toThrow("Invalid request timeout option: 0");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_timeout=10.32"),
- ).toThrow("Invalid request timeout option: 10.32");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_timeout=-60"),
- ).toThrow("Invalid request timeout option: -60");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_timeout=60x"),
- ).toThrow("Invalid request timeout option, not a number: '60x'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_timeout=a60"),
- ).toThrow("Invalid request timeout option, not a number: 'a60'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;request_timeout=6w0"),
- ).toThrow("Invalid request timeout option, not a number: '6w0'");
- });
-
- it("can parse retry_timeout config", function () {
- let options = SenderOptions.fromConfig(
- "http::addr=host:9000;retry_timeout=60",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=0",
+ ),
+ ).rejects.toThrow("Invalid request timeout option: 0");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=10.32",
+ ),
+ ).rejects.toThrow("Invalid request timeout option: 10.32");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=-60",
+ ),
+ ).rejects.toThrow("Invalid request timeout option: -60");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=60x",
+ ),
+ ).rejects.toThrow("Invalid request timeout option, not a number: '60x'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=a60",
+ ),
+ ).rejects.toThrow("Invalid request timeout option, not a number: 'a60'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;request_timeout=6w0",
+ ),
+ ).rejects.toThrow("Invalid request timeout option, not a number: '6w0'");
+ });
+
+ it("can parse retry_timeout config", async function () {
+ let options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=60",
);
expect(options.protocol).toBe("http");
expect(options.retry_timeout).toBe(60);
- options = SenderOptions.fromConfig("http::addr=host:9000;retry_timeout=0");
+ options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=0",
+ );
expect(options.protocol).toBe("http");
expect(options.retry_timeout).toBe(0);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;retry_timeout=-60"),
- ).toThrow("Invalid retry timeout option: -60");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;retry_timeout=-60.444"),
- ).toThrow("Invalid retry timeout option: -60.444");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;retry_timeout=60x"),
- ).toThrow("Invalid retry timeout option, not a number: '60x'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;retry_timeout=a60"),
- ).toThrow("Invalid retry timeout option, not a number: 'a60'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;retry_timeout=6w0"),
- ).toThrow("Invalid retry timeout option, not a number: '6w0'");
- });
-
- it("can parse max_name_len config", function () {
- const options = SenderOptions.fromConfig(
- "http::addr=host:9000;max_name_len=30",
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=-60",
+ ),
+ ).rejects.toThrow("Invalid retry timeout option: -60");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=-60.444",
+ ),
+ ).rejects.toThrow("Invalid retry timeout option: -60.444");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=60x",
+ ),
+ ).rejects.toThrow("Invalid retry timeout option, not a number: '60x'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=a60",
+ ),
+ ).rejects.toThrow("Invalid retry timeout option, not a number: 'a60'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;retry_timeout=6w0",
+ ),
+ ).rejects.toThrow("Invalid retry timeout option, not a number: '6w0'");
+ });
+
+ it("can parse max_name_len config", async function () {
+ const options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=30",
);
expect(options.protocol).toBe("http");
expect(options.max_name_len).toBe(30);
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;max_name_len=0"),
- ).toThrow("Invalid max name length option: 0");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;max_name_len=10.32"),
- ).toThrow("Invalid max name length option: 10.32");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;max_name_len=-60"),
- ).toThrow("Invalid max name length option: -60");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;max_name_len=60x"),
- ).toThrow("Invalid max name length option, not a number: '60x'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;max_name_len=a60"),
- ).toThrow("Invalid max name length option, not a number: 'a60'");
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000;max_name_len=6w0"),
- ).toThrow("Invalid max name length option, not a number: '6w0'");
- });
-
- it("can take a custom logger", function () {
- const options = SenderOptions.fromConfig("http::addr=host:9000", {
- log: console.log,
- });
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=0",
+ ),
+ ).rejects.toThrow("Invalid max name length option: 0");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=10.32",
+ ),
+ ).rejects.toThrow("Invalid max name length option: 10.32");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=-60",
+ ),
+ ).rejects.toThrow("Invalid max name length option: -60");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=60x",
+ ),
+ ).rejects.toThrow("Invalid max name length option, not a number: '60x'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=a60",
+ ),
+ ).rejects.toThrow("Invalid max name length option, not a number: 'a60'");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2;max_name_len=6w0",
+ ),
+ ).rejects.toThrow("Invalid max name length option, not a number: '6w0'");
+ });
+
+ it("can take a custom logger", async function () {
+ const options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ {
+ log: console.log,
+ },
+ );
expect(options.protocol).toBe("http");
expect(options.log).toBe(console.log);
- expect(() =>
- // @ts-expect-error - Testing invalid input
- SenderOptions.fromConfig("http::addr=host:9000", { log: 1234 }),
- ).toThrow("Invalid logging function");
- expect(() =>
- // @ts-expect-error - Testing invalid input
- SenderOptions.fromConfig("http::addr=host:9000", { log: "hoppa" }),
- ).toThrow("Invalid logging function");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ // @ts-expect-error - Testing invalid input
+ { log: 1234 },
+ ),
+ ).rejects.toThrow("Invalid logging function");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ {
+ // @ts-expect-error - Testing invalid input
+ log: "hoppa",
+ },
+ ),
+ ).rejects.toThrow("Invalid logging function");
});
- it("can take a custom agent", function () {
+ it("can take a custom agent", async function () {
const agent = new Agent({ connect: { keepAlive: true } });
- const options = SenderOptions.fromConfig("http::addr=host:9000", {
- agent: agent,
- });
+ const options = await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ {
+ agent: agent,
+ },
+ );
expect(options.protocol).toBe("http");
const symbols = Object.getOwnPropertySymbols(options.agent);
expect(agent[symbols[6]]).toEqual({ connect: { keepAlive: true } });
- agent.destroy();
-
- expect(() =>
- SenderOptions.fromConfig("http::addr=host:9000", {
- // @ts-expect-error - Testing invalid input
- agent: { keepAlive: true },
- }),
- ).toThrow("Invalid HTTP agent");
- expect(() =>
- // @ts-expect-error - Testing invalid input
- SenderOptions.fromConfig("http::addr=host:9000", { agent: 4567 }),
- ).toThrow("Invalid HTTP agent");
- expect(() =>
- // @ts-expect-error - Testing invalid input
- SenderOptions.fromConfig("http::addr=host:9000", { agent: "hopp" }),
- ).toThrow("Invalid HTTP agent");
+ await agent.destroy();
+
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ {
+ // @ts-expect-error - Testing invalid input
+ agent: { keepAlive: true },
+ },
+ ),
+ ).rejects.toThrow("Invalid HTTP agent");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ // @ts-expect-error - Testing invalid input
+ { agent: 4567 },
+ ),
+ ).rejects.toThrow("Invalid HTTP agent");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ "http::addr=host:9000;protocol_version=2",
+ {
+ // @ts-expect-error - Testing invalid input
+ agent: "hopp",
+ },
+ ),
+ ).rejects.toThrow("Invalid HTTP agent");
});
});
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 7f00f80..4f43623 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { Sender } from "../src";
+import { SenderOptions } from "../src/options";
describe("Client interop test suite", function () {
it("runs client tests as per json test config", async function () {
@@ -15,12 +16,14 @@ describe("Client interop test suite", function () {
for (const testCase of testCases) {
console.info(`test name: ${testCase.testName}`);
- const sender = new Sender({
- protocol: "tcp",
- host: "host",
- auto_flush: false,
- init_buf_size: 1024,
- });
+ const sender = new Sender(
+ await SenderOptions.resolveAuto({
+ protocol: "tcp",
+ host: "host",
+ auto_flush: false,
+ init_buf_size: 1024,
+ }),
+ );
let errorMessage: string;
try {
@@ -94,6 +97,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws on invalid timestamp unit", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
auto_flush: false,
init_buf_size: 1024,
@@ -143,6 +147,7 @@ describe("Sender message builder test suite (anything not covered in client inte
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 256,
});
@@ -165,6 +170,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as number", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -182,6 +188,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as ns number", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -199,6 +206,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as us number", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -216,6 +224,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as ms number", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -233,6 +242,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as BigInt", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -250,6 +260,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as ns BigInt", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -267,6 +278,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as us BigInt", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -284,6 +296,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports timestamp field as ms BigInt", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -301,6 +314,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws on invalid designated timestamp unit", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -320,6 +334,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports setting designated us timestamp as number from client", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -337,6 +352,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports setting designated ms timestamp as number from client", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -354,6 +370,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports setting designated timestamp as BigInt from client", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -371,6 +388,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports setting designated ns timestamp as BigInt from client", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -388,6 +406,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports setting designated us timestamp as BigInt from client", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -405,6 +424,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("supports setting designated ms timestamp as BigInt from client", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -422,6 +442,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if table name is not a string", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -435,6 +456,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if table name is too long", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -450,6 +472,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if table name is set more times", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -462,6 +485,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if symbol name is not a string", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -475,6 +499,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if symbol name is empty string", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -487,6 +512,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if column name is not a string", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -500,6 +526,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if column name is empty string", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -512,6 +539,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if column name is too long", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -530,6 +558,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if column value is not the right type", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -543,6 +572,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if adding column without setting table name", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -555,6 +585,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if adding symbol without setting table name", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -567,6 +598,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if adding symbol after columns", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -584,6 +616,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("returns null if preparing an empty buffer for send", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -597,6 +630,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("leaves unfinished rows in the sender's buffer when preparing a copy of the buffer for send", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -620,6 +654,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if a float is passed as integer field", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -632,6 +667,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if a float is passed as timestamp field", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -644,6 +680,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if designated timestamp is not an integer or bigint", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -663,6 +700,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if designated timestamp is invalid", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -680,6 +718,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("throws exception if designated timestamp is set without any fields added", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -696,6 +735,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("extends the size of the buffer if data does not fit", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 8,
});
@@ -728,7 +768,7 @@ describe("Sender message builder test suite (anything not covered in client inte
});
it("throws exception if tries to extend the size of the buffer above max buffer size", async function () {
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
"tcp::addr=host;init_buf_size=8;max_buf_size=64;",
);
expect(bufferSize(sender)).toBe(8);
@@ -761,6 +801,7 @@ describe("Sender message builder test suite (anything not covered in client inte
it("is possible to clear the buffer by calling reset()", async function () {
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
diff --git a/test/sender.config.test.ts b/test/sender.config.test.ts
index 8b7d4d1..ac11c77 100644
--- a/test/sender.config.test.ts
+++ b/test/sender.config.test.ts
@@ -7,46 +7,51 @@ import { log } from "../src/logging";
describe("Sender configuration options suite", function () {
it("creates a sender from a configuration string", async function () {
- await Sender.fromConfig("tcps::addr=hostname;").close();
+ const sender = await Sender.fromConfig("tcps::addr=hostname;");
+ await sender.close();
});
it("creates a sender from a configuration string picked up from env", async function () {
- process.env.QDB_CLIENT_CONF = "https::addr=hostname;";
- await Sender.fromEnv().close();
+ process.env.QDB_CLIENT_CONF = "https::addr=hostname;protocol_version=1";
+ await (await Sender.fromEnv()).close();
});
it("throws exception if the username or the token is missing when TCP transport is used", async function () {
- await expect(
- async () =>
- await Sender.fromConfig("tcp::addr=hostname;username=bobo;").close(),
- ).rejects.toThrow(
+ await expect(async () => {
+ const sender = await Sender.fromConfig(
+ "tcp::addr=hostname;username=bobo;",
+ );
+ await sender.close();
+ }).rejects.toThrow(
"TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
);
- await expect(
- async () =>
- await Sender.fromConfig("tcp::addr=hostname;token=bobo_token;").close(),
- ).rejects.toThrow(
+ await expect(async () => {
+ const sender = await Sender.fromConfig(
+ "tcp::addr=hostname;token=bobo_token;",
+ );
+ await sender.close();
+ }).rejects.toThrow(
"TCP transport requires a username and a private key for authentication, please, specify the 'username' and 'token' config options",
);
});
it("throws exception if tls_roots or tls_roots_password is used", async function () {
- await expect(
- async () =>
- await Sender.fromConfig(
- "tcps::addr=hostname;username=bobo;tls_roots=bla;",
- ).close(),
- ).rejects.toThrow(
+ await expect(async () => {
+ const sender = await Sender.fromConfig(
+ "tcps::addr=hostname;username=bobo;tls_roots=bla;",
+ );
+ await sender.close();
+ }).rejects.toThrow(
"'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
- await expect(
- async () =>
- await Sender.fromConfig(
- "tcps::addr=hostname;token=bobo_token;tls_roots_password=bla;",
- ).close(),
- ).rejects.toThrow(
+ await expect(async () => {
+ const sender = await Sender.fromConfig(
+ "tcps::addr=hostname;token=bobo_token;tls_roots_password=bla;",
+ );
+ await sender.close();
+ }).rejects.toThrow(
"'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead",
);
});
@@ -54,7 +59,9 @@ describe("Sender configuration options suite", function () {
it("throws exception if connect() is called when http transport is used", async function () {
let sender: Sender;
await expect(async () => {
- sender = Sender.fromConfig("http::addr=hostname");
+ sender = await Sender.fromConfig(
+ "http::addr=hostname;protocol_version=2",
+ );
await sender.connect();
}).rejects.toThrow("'connect()' is not required for HTTP transport");
await sender.close();
@@ -108,6 +115,7 @@ describe("Sender options test suite", function () {
it("sets default buffer size if init_buf_size is not set", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
});
expect(bufferSize(sender)).toBe(DEFAULT_BUFFER_SIZE);
@@ -117,6 +125,7 @@ describe("Sender options test suite", function () {
it("sets the requested buffer size if init_buf_size is set", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
@@ -127,6 +136,7 @@ describe("Sender options test suite", function () {
it("sets default buffer size if init_buf_size is set to null", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
init_buf_size: null,
});
@@ -137,6 +147,7 @@ describe("Sender options test suite", function () {
it("sets default buffer size if init_buf_size is set to undefined", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
init_buf_size: undefined,
});
@@ -147,6 +158,7 @@ describe("Sender options test suite", function () {
it("sets default buffer size if init_buf_size is not a number", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
// @ts-expect-error - Testing invalid options
init_buf_size: "1024",
@@ -169,6 +181,7 @@ describe("Sender options test suite", function () {
};
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
// @ts-expect-error - Testing deprecated option
bufferSize: 2048,
@@ -192,6 +205,7 @@ describe("Sender options test suite", function () {
};
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
// @ts-expect-error - Testing deprecated option
copy_buffer: false,
@@ -214,6 +228,7 @@ describe("Sender options test suite", function () {
};
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
// @ts-expect-error - Testing deprecated option
copyBuffer: false,
@@ -223,7 +238,11 @@ describe("Sender options test suite", function () {
});
it("sets default max buffer size if max_buf_size is not set", async function () {
- const sender = new Sender({ protocol: "http", host: "host" });
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "2",
+ host: "host",
+ });
expect(maxBufferSize(sender)).toBe(DEFAULT_MAX_BUFFER_SIZE);
await sender.close();
});
@@ -231,6 +250,7 @@ describe("Sender options test suite", function () {
it("sets the requested max buffer size if max_buf_size is set", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
max_buf_size: 131072,
});
@@ -243,6 +263,7 @@ describe("Sender options test suite", function () {
async () =>
await new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
max_buf_size: 8192,
init_buf_size: 16384,
@@ -255,6 +276,7 @@ describe("Sender options test suite", function () {
it("sets default max buffer size if max_buf_size is set to null", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
max_buf_size: null,
});
@@ -265,6 +287,7 @@ describe("Sender options test suite", function () {
it("sets default max buffer size if max_buf_size is set to undefined", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
max_buf_size: undefined,
});
@@ -275,6 +298,7 @@ describe("Sender options test suite", function () {
it("sets default max buffer size if max_buf_size is not a number", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
// @ts-expect-error - Testing invalid value
max_buf_size: "1024",
@@ -284,7 +308,11 @@ describe("Sender options test suite", function () {
});
it("uses default logger if log function is not set", async function () {
- const sender = new Sender({ protocol: "http", host: "host" });
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "1",
+ host: "host",
+ });
expect(logger(sender)).toBe(log);
await sender.close();
});
@@ -293,6 +321,7 @@ describe("Sender options test suite", function () {
const testFunc = () => {};
const sender = new Sender({
protocol: "http",
+ protocol_version: "1",
host: "host",
log: testFunc,
});
@@ -301,7 +330,12 @@ describe("Sender options test suite", function () {
});
it("uses default logger if log is set to null", async function () {
- const sender = new Sender({ protocol: "http", host: "host", log: null });
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "1",
+ host: "host",
+ log: null,
+ });
expect(logger(sender)).toBe(log);
await sender.close();
});
@@ -309,6 +343,7 @@ describe("Sender options test suite", function () {
it("uses default logger if log is set to undefined", async function () {
const sender = new Sender({
protocol: "http",
+ protocol_version: "2",
host: "host",
log: undefined,
});
@@ -317,8 +352,13 @@ describe("Sender options test suite", function () {
});
it("uses default logger if log is not a function", async function () {
- // @ts-expect-error - Testing invalid options
- const sender = new Sender({ protocol: "http", host: "host", log: "" });
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "2",
+ host: "host",
+ // @ts-expect-error - Testing invalid options
+ log: "",
+ });
expect(logger(sender)).toBe(log);
await sender.close();
});
@@ -330,6 +370,7 @@ describe("Sender auth config checks suite", function () {
async () =>
await new Sender({
protocol: "tcp",
+ protocol_version: "2",
host: "host",
auth: {
token: "privateKey",
@@ -346,6 +387,7 @@ describe("Sender auth config checks suite", function () {
async () =>
await new Sender({
protocol: "tcp",
+ protocol_version: "2",
host: "host",
auth: {
keyId: "",
diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts
index eff352e..fd5a5c7 100644
--- a/test/sender.integration.test.ts
+++ b/test/sender.integration.test.ts
@@ -4,6 +4,7 @@ import { GenericContainer, StartedTestContainer } from "testcontainers";
import http from "http";
import { Sender } from "../src";
+import { SenderOptions } from "../src/options";
const HTTP_OK = 200;
@@ -101,11 +102,13 @@ describe("Sender tests with containerized QuestDB instance", () => {
});
it("can ingest data via TCP and run queries", async () => {
- const sender = new Sender({
- protocol: "tcp",
- host: container.getHost(),
- port: container.getMappedPort(QUESTDB_ILP_PORT),
- });
+ const sender = new Sender(
+ await SenderOptions.resolveAuto({
+ protocol: "tcp",
+ host: container.getHost(),
+ port: container.getMappedPort(QUESTDB_ILP_PORT),
+ }),
+ );
await sender.connect();
const tableName = "test_tcp";
@@ -182,7 +185,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
{ name: "timestamp", type: "TIMESTAMP" },
];
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_interval=0;auto_flush_rows=1`,
);
@@ -251,7 +254,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
{ name: "timestamp", type: "TIMESTAMP" },
];
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_interval=1;auto_flush_rows=0`,
);
@@ -319,11 +322,13 @@ describe("Sender tests with containerized QuestDB instance", () => {
});
it("does not duplicate rows if await is missing when calling flush", async () => {
- const sender = new Sender({
- protocol: "tcp",
- host: container.getHost(),
- port: container.getMappedPort(QUESTDB_ILP_PORT),
- });
+ const sender = new Sender(
+ await SenderOptions.resolveAuto({
+ protocol: "tcp",
+ host: container.getHost(),
+ port: container.getMappedPort(QUESTDB_ILP_PORT),
+ }),
+ );
await sender.connect();
const tableName = "test2";
@@ -367,7 +372,7 @@ describe("Sender tests with containerized QuestDB instance", () => {
});
it("ingests all data without loss under high load with auto-flush", async () => {
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`tcp::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_ILP_PORT)};auto_flush_rows=5;auto_flush_interval=1`,
);
await sender.connect();
diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts
index ff22b96..67127ed 100644
--- a/test/sender.transport.test.ts
+++ b/test/sender.transport.test.ts
@@ -54,12 +54,12 @@ describe("Sender HTTP suite", function () {
afterAll(async function () {
await mockHttp.stop();
await mockHttps.stop();
- });
+ }, 30000);
it("can ingest via HTTP", async function () {
mockHttp.reset();
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
);
await sendData(sender);
@@ -71,23 +71,23 @@ describe("Sender HTTP suite", function () {
it("can ingest via HTTPS", async function () {
mockHttps.reset();
- const senderCertCheckFail = Sender.fromConfig(
- `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT}`,
- );
- await expect(sendData(senderCertCheckFail)).rejects.toThrowError(
- "self-signed certificate in certificate chain",
+ const senderCertCheckFail = await Sender.fromConfig(
+ `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};protocol_version=2`,
);
+ await expect(
+ async () => await sendData(senderCertCheckFail),
+ ).rejects.toThrowError("self-signed certificate in certificate chain");
await senderCertCheckFail.close();
- const senderWithCA = Sender.fromConfig(
- `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};tls_ca=test/certs/ca/ca.crt`,
+ const senderWithCA = await Sender.fromConfig(
+ `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};protocol_version=2;tls_ca=test/certs/ca/ca.crt`,
);
await sendData(senderWithCA);
expect(mockHttps.numOfRequests).toEqual(1);
await senderWithCA.close();
- const senderVerifyOff = Sender.fromConfig(
- `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
+ const senderVerifyOff = await Sender.fromConfig(
+ `https::addr=${PROXY_HOST}:${MOCK_HTTPS_PORT};protocol_version=2;tls_verify=unsafe_off`,
);
await sendData(senderVerifyOff);
expect(mockHttps.numOfRequests).toEqual(2);
@@ -97,85 +97,85 @@ describe("Sender HTTP suite", function () {
it("can ingest via HTTP with basic auth", async function () {
mockHttp.reset({ username: "user1", password: "pwd" });
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1;password=pwd`,
);
await sendData(sender);
expect(mockHttp.numOfRequests).toEqual(1);
await sender.close();
- const senderFailPwd = Sender.fromConfig(
+ const senderFailPwd = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1;password=xyz`,
);
- await expect(sendData(senderFailPwd)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailPwd),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailPwd.close();
- const senderFailMissingPwd = Sender.fromConfig(
+ const senderFailMissingPwd = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=user1z`,
);
- await expect(sendData(senderFailMissingPwd)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailMissingPwd),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailMissingPwd.close();
- const senderFailUsername = Sender.fromConfig(
+ const senderFailUsername = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};username=xyz;password=pwd`,
);
- await expect(sendData(senderFailUsername)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailUsername),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailUsername.close();
- const senderFailMissingUsername = Sender.fromConfig(
+ const senderFailMissingUsername = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};password=pwd`,
);
- await expect(sendData(senderFailMissingUsername)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailMissingUsername),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailMissingUsername.close();
- const senderFailMissing = Sender.fromConfig(
+ const senderFailMissing = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
);
- await expect(sendData(senderFailMissing)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailMissing),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailMissing.close();
});
it("can ingest via HTTP with token auth", async function () {
mockHttp.reset({ token: "abcdefghijkl123" });
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};token=abcdefghijkl123`,
);
await sendData(sender);
expect(mockHttp.numOfRequests).toBe(1);
await sender.close();
- const senderFailToken = Sender.fromConfig(
+ const senderFailToken = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};token=xyz`,
);
- await expect(sendData(senderFailToken)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailToken),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailToken.close();
- const senderFailMissing = Sender.fromConfig(
+ const senderFailMissing = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
);
- await expect(sendData(senderFailMissing)).rejects.toThrowError(
- "HTTP request failed, statusCode=401",
- );
+ await expect(
+ async () => await sendData(senderFailMissing),
+ ).rejects.toThrowError("HTTP request failed, statusCode=401");
await senderFailMissing.close();
});
it("can retry via HTTP", async function () {
mockHttp.reset({ responseCodes: [204, 500, 523, 504, 500] });
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
);
await sendData(sender);
@@ -192,10 +192,10 @@ describe("Sender HTTP suite", function () {
responseDelays: [1000, 1000, 1000],
});
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=1000`,
);
- await expect(sendData(sender)).rejects.toThrowError(
+ await expect(async () => await sendData(sender)).rejects.toThrowError(
"HTTP request timeout, no response from server in time",
);
await sender.close();
@@ -209,10 +209,10 @@ describe("Sender HTTP suite", function () {
responseDelays: [1000],
});
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=0;request_timeout=100`,
);
- await expect(sendData(sender)).rejects.toThrowError(
+ await expect(async () => await sendData(sender)).rejects.toThrowError(
"HTTP request timeout, no response from server in time",
);
await sender.close();
@@ -224,7 +224,7 @@ describe("Sender HTTP suite", function () {
responseDelays: [2000, 2000],
});
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};retry_timeout=600000;request_timeout=1000`,
);
await sendData(sender);
@@ -240,7 +240,7 @@ describe("Sender HTTP suite", function () {
const senders: Sender[] = [];
const promises: Promise[] = [];
for (let i = 0; i < num; i++) {
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
{ agent: agent },
);
@@ -261,7 +261,7 @@ describe("Sender HTTP suite", function () {
mockHttp.reset();
const agent = new Agent({ pipelining: 3 });
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT}`,
{ agent: agent },
);
@@ -282,7 +282,7 @@ describe("Sender HTTP suite", function () {
mockHttp.reset();
const agent = new http.Agent({ maxSockets: 128 });
- const sender = Sender.fromConfig(
+ const sender = await Sender.fromConfig(
`http::addr=${PROXY_HOST}:${MOCK_HTTP_PORT};legacy_http=on`,
{ agent: agent },
);
@@ -314,6 +314,7 @@ describe("Sender TCP suite", function () {
async function createSender(auth: SenderOptions["auth"], secure = false) {
const sender = new Sender({
protocol: secure ? "tcps" : "tcp",
+ protocol_version: "1",
port: PROXY_PORT,
host: PROXY_HOST,
auth: auth,
@@ -390,6 +391,7 @@ describe("Sender TCP suite", function () {
const proxy = await createProxy(true);
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
port: PROXY_PORT,
host: PROXY_HOST,
jwk: JWK,
@@ -470,7 +472,11 @@ describe("Sender TCP suite", function () {
});
it("fails to send data if not connected", async function () {
- const sender = new Sender({ protocol: "tcp", host: "localhost" });
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "localhost",
+ });
await expect(async () => {
await sender.table("test").symbol("location", "us").atNow();
await sender.flush();
@@ -492,6 +498,7 @@ describe("Sender TCP suite", function () {
const proxy = await createProxy(true, proxyOptions);
const sender = new Sender({
protocol: "tcps",
+ protocol_version: "1",
port: PROXY_PORT,
host: PROXY_HOST,
auth: AUTH,
@@ -506,22 +513,22 @@ describe("Sender TCP suite", function () {
it("can disable the server certificate check", async function () {
const proxy = await createProxy(true, proxyOptions);
- const senderCertCheckFail = Sender.fromConfig(
- `tcps::addr=${PROXY_HOST}:${PROXY_PORT}`,
+ const senderCertCheckFail = await Sender.fromConfig(
+ `tcps::addr=${PROXY_HOST}:${PROXY_PORT};protocol_version=1`,
);
await expect(
async () => await senderCertCheckFail.connect(),
).rejects.toThrow("self-signed certificate in certificate chain");
await senderCertCheckFail.close();
- const senderCertCheckOn = Sender.fromConfig(
- `tcps::addr=${PROXY_HOST}:${PROXY_PORT};tls_ca=test/certs/ca/ca.crt`,
+ const senderCertCheckOn = await Sender.fromConfig(
+ `tcps::addr=${PROXY_HOST}:${PROXY_PORT};protocol_version=1;tls_ca=test/certs/ca/ca.crt`,
);
await senderCertCheckOn.connect();
await senderCertCheckOn.close();
- const senderCertCheckOff = Sender.fromConfig(
- `tcps::addr=${PROXY_HOST}:${PROXY_PORT};tls_verify=unsafe_off`,
+ const senderCertCheckOff = await Sender.fromConfig(
+ `tcps::addr=${PROXY_HOST}:${PROXY_PORT};protocol_version=1;tls_verify=unsafe_off`,
);
await senderCertCheckOff.connect();
await senderCertCheckOff.close();
@@ -556,6 +563,7 @@ describe("Sender TCP suite", function () {
const proxy = await createProxy();
const sender = new Sender({
protocol: "tcp",
+ protocol_version: "1",
port: PROXY_PORT,
host: PROXY_HOST,
log: log,
diff --git a/test/util/mockhttp.ts b/test/util/mockhttp.ts
index 45de985..75e3a14 100644
--- a/test/util/mockhttp.ts
+++ b/test/util/mockhttp.ts
@@ -7,6 +7,9 @@ type MockConfig = {
username?: string;
password?: string;
token?: string;
+ settings?: {
+ config: { "line.proto.support.versions": number[] };
+ };
};
class MockHttp {
@@ -18,7 +21,13 @@ class MockHttp {
this.reset();
}
- reset(mockConfig = {}) {
+ reset(mockConfig: MockConfig = {}) {
+ if (!mockConfig.settings) {
+ mockConfig.settings = {
+ config: { "line.proto.support.versions": [1, 2] },
+ };
+ }
+
this.mockConfig = mockConfig;
this.numOfRequests = 0;
}
@@ -33,35 +42,48 @@ class MockHttp {
this.server = serverCreator(
options,
(req: http.IncomingMessage, res: http.ServerResponse) => {
- const authFailed = checkAuthHeader(this.mockConfig, req);
+ const { url, method } = req;
+ if (url.startsWith("/write") && method === "POST") {
+ const authFailed = checkAuthHeader(this.mockConfig, req);
- const body: Uint8Array[] = [];
- req.on("data", (chunk: Uint8Array) => {
- body.push(chunk);
- });
+ const body: Uint8Array[] = [];
+ req.on("data", (chunk: Uint8Array) => {
+ body.push(chunk);
+ });
- req.on("end", async () => {
- console.info(`Received data: ${Buffer.concat(body)}`);
- this.numOfRequests++;
-
- const delay =
- this.mockConfig.responseDelays &&
- this.mockConfig.responseDelays.length > 0
- ? this.mockConfig.responseDelays.pop()
- : undefined;
- if (delay) {
- await sleep(delay);
- }
-
- const responseCode = authFailed
- ? 401
- : this.mockConfig.responseCodes &&
- this.mockConfig.responseCodes.length > 0
- ? this.mockConfig.responseCodes.pop()
- : 204;
- res.writeHead(responseCode);
- res.end();
- });
+ req.on("end", async () => {
+ console.info(`Received data: ${Buffer.concat(body)}`);
+ this.numOfRequests++;
+
+ const delay =
+ this.mockConfig.responseDelays &&
+ this.mockConfig.responseDelays.length > 0
+ ? this.mockConfig.responseDelays.pop()
+ : undefined;
+ if (delay) {
+ await sleep(delay);
+ }
+
+ const responseCode = authFailed
+ ? 401
+ : this.mockConfig.responseCodes &&
+ this.mockConfig.responseCodes.length > 0
+ ? this.mockConfig.responseCodes.pop()
+ : 204;
+ res.writeHead(responseCode);
+ res.end();
+ });
+ } else if (url === "/settings" && method === "GET") {
+ const settingsStr = JSON.stringify(this.mockConfig.settings);
+ console.info(`Settings reply: ${settingsStr}`);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(settingsStr);
+ return;
+ } else {
+ console.info(`No handler for: ${method} ${url}`);
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end("Not found");
+ }
},
);
@@ -80,8 +102,13 @@ class MockHttp {
async stop() {
if (this.server) {
- this.server.close();
+ return new Promise((resolve, reject) => {
+ this.server.close((err) => {
+ err ? reject(err) : resolve(true);
+ });
+ });
}
+ return true;
}
}
From 87c26a472facd254268b5912409883eb39e96083 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 24 Jul 2025 17:18:43 +0100
Subject: [PATCH 08/38] more tests
---
test/options.test.ts | 83 +++++++++++++++++++++++++++++++++++++++++--
test/util/mockhttp.ts | 2 +-
2 files changed, 81 insertions(+), 4 deletions(-)
diff --git a/test/options.test.ts b/test/options.test.ts
index bfbdf3e..19ce449 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -267,8 +267,12 @@ describe("Configuration string parser suite", function () {
"Invalid protocol version: 'automatic', accepted values: 'auto', '1', '2'",
);
- // defaults
- let options = await SenderOptions.fromConfig("tcp::addr=localhost");
+ let options: SenderOptions;
+
+ // defaults with supported versions: 1,2
+ mockHttp.reset();
+ mockHttps.reset();
+ options = await SenderOptions.fromConfig("tcp::addr=localhost");
expect(options.protocol_version).toBe("1");
options = await SenderOptions.fromConfig("tcps::addr=localhost");
expect(options.protocol_version).toBe("1");
@@ -281,7 +285,80 @@ describe("Configuration string parser suite", function () {
);
expect(options.protocol_version).toBe("2");
- // auto, 1, 2 with each protocol (tcp, tcps, http, https)
+ // defaults with supported versions: 1
+ const only1 = {
+ settings: {
+ config: { "line.proto.support.versions": [1] },
+ },
+ };
+ mockHttp.reset(only1);
+ mockHttps.reset(only1);
+ options = await SenderOptions.fromConfig("tcp::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig("tcps::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT}`,
+ );
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ );
+ expect(options.protocol_version).toBe("1");
+
+ // defaults with no supported versions
+ const noVersions = {
+ settings: {
+ config: {},
+ },
+ };
+ mockHttp.reset(noVersions);
+ mockHttps.reset(noVersions);
+ options = await SenderOptions.fromConfig("tcp::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig("tcps::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT}`,
+ );
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ );
+ expect(options.protocol_version).toBe("1");
+
+ // defaults with no match with supported versions
+ const no1and2 = {
+ settings: {
+ config: { "line.proto.support.versions": [3, 5] },
+ },
+ };
+ mockHttp.reset(no1and2);
+ mockHttps.reset(no1and2);
+ options = await SenderOptions.fromConfig("tcp::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ options = await SenderOptions.fromConfig("tcps::addr=localhost");
+ expect(options.protocol_version).toBe("1");
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ `http::addr=localhost:${MOCK_HTTP_PORT}`,
+ ),
+ ).rejects.toThrow(
+ "Unsupported protocol versions received from server: 3,5",
+ );
+ await expect(
+ async () =>
+ await SenderOptions.fromConfig(
+ `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ ),
+ ).rejects.toThrow(
+ "Unsupported protocol versions received from server: 3,5",
+ );
+
+ // auto, 1, 2 with each protocol (tcp, tcps, http, https), supported versions: 1,2
+ mockHttp.reset();
+ mockHttps.reset();
options = await SenderOptions.fromConfig(
"tcp::addr=localhost;protocol_version=1",
);
diff --git a/test/util/mockhttp.ts b/test/util/mockhttp.ts
index 75e3a14..c3219fa 100644
--- a/test/util/mockhttp.ts
+++ b/test/util/mockhttp.ts
@@ -8,7 +8,7 @@ type MockConfig = {
password?: string;
token?: string;
settings?: {
- config: { "line.proto.support.versions": number[] };
+ config?: { "line.proto.support.versions"?: number[] };
};
};
From c7b25252e0638ce21c20694c85f65e7f0047a9b0 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Sat, 26 Jul 2025 01:38:37 +0100
Subject: [PATCH 09/38] feat(nodejs): array support
---
src/buffer/base.ts | 76 ++++++++++++++++++------
src/buffer/bufferv1.ts | 4 ++
src/buffer/bufferv2.ts | 24 ++++++++
src/buffer/index.ts | 2 +
src/sender.ts | 5 ++
src/utils.ts | 13 +++++
test/sender.buffer.test.ts | 117 +++++++++++++++++++++++++++++++++++++
7 files changed, 224 insertions(+), 17 deletions(-)
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index 442d7c5..e8c0de3 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -11,6 +11,7 @@ import {
} from "./index";
import {
isInteger,
+ getDimensions,
timestampToMicros,
timestampToNanos,
TimestampUnit,
@@ -235,6 +236,8 @@ abstract class SenderBufferBase implements SenderBuffer {
*/
abstract floatColumn(name: string, value: number): SenderBuffer;
+ abstract arrayColumn(name: string, value: unknown[]): SenderBuffer;
+
/**
* Write an integer column with its value into the buffer of the sender.
*
@@ -367,36 +370,66 @@ abstract class SenderBufferBase implements SenderBuffer {
this.writeEscaped(name);
this.write("=");
writeValue();
+ this.assertBufferOverflow();
this.hasColumns = true;
}
protected write(data: string) {
this.position += this.buffer.write(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
}
protected writeByte(data: number) {
this.position = this.buffer.writeInt8(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
+ }
+
+ protected writeInt(data: number) {
+ this.position = this.buffer.writeInt32LE(data, this.position);
}
protected writeDouble(data: number) {
this.position = this.buffer.writeDoubleLE(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
+ }
+
+ protected writeArray(arr: unknown[]) {
+ const dimensions = getDimensions(arr);
+ this.checkCapacity([], 1 + dimensions.length * 4);
+ this.writeByte(dimensions.length);
+ let numOfElements = 1;
+ for (let i = 0; i < dimensions.length; i++) {
+ numOfElements *= dimensions[i];
+ this.writeInt(dimensions[i]);
+ }
+
+ this.checkCapacity([], numOfElements * 8);
+ this.writeArrayValues(arr, dimensions);
+ }
+
+ private writeArrayValues(arr: unknown[], dimensions: number[]) {
+ if (Array.isArray(arr[0])) {
+ const length = arr[0].length;
+ for (let i = 0; i < arr.length; i++) {
+ const subArray = arr[i] as unknown[];
+ if (subArray.length !== length) {
+ throw new Error(
+ `length does not match array dimensions [dimensions=[${dimensions}], length=${subArray.length}]`,
+ );
+ }
+ this.writeArrayValues(subArray, dimensions);
+ }
+ } else {
+ const dataType = typeof arr[0];
+ switch (dataType) {
+ case "number":
+ for (let i = 0; i < arr.length; i++) {
+ this.position = this.buffer.writeDoubleLE(
+ arr[i] as number,
+ this.position,
+ );
+ }
+ break;
+ default:
+ throw new Error(`unsupported array type [type=${dataType}]`);
+ }
}
}
@@ -436,6 +469,15 @@ abstract class SenderBufferBase implements SenderBuffer {
}
}
}
+
+ private assertBufferOverflow() {
+ if (this.position > this.bufferSize) {
+ // should never happen, if checkCapacity() is correctly used
+ throw new Error(
+ `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
+ );
+ }
+ }
}
export { SenderBufferBase };
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
index aa2a37e..0efe7c5 100644
--- a/src/buffer/bufferv1.ts
+++ b/src/buffer/bufferv1.ts
@@ -21,6 +21,10 @@ class SenderBufferV1 extends SenderBufferBase {
);
return this;
}
+
+ arrayColumn(): SenderBuffer {
+ throw new Error("Arrays are not supported in protocol v1");
+ }
}
export { SenderBufferV1 };
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index e21fdb1..168d5b5 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -3,7 +3,12 @@ import { SenderOptions } from "../options";
import { SenderBuffer } from "./index";
import { SenderBufferBase } from "./base";
+const COLUMN_TYPE_DOUBLE: number = 10;
+const COLUMN_TYPE_NULL: number = 33;
+
+const ENTITY_TYPE_ARRAY: number = 14;
const ENTITY_TYPE_DOUBLE: number = 16;
+
const EQUALS_SIGN: number = "=".charCodeAt(0);
class SenderBufferV2 extends SenderBufferBase {
@@ -25,6 +30,25 @@ class SenderBufferV2 extends SenderBufferBase {
);
return this;
}
+
+ arrayColumn(name: string, value: unknown[]): SenderBuffer {
+ if (value && !Array.isArray(value)) {
+ throw new Error(`Value must be an array, received ${value}`);
+ }
+ this.writeColumn(name, value, () => {
+ this.checkCapacity([], 3);
+ this.writeByte(EQUALS_SIGN);
+ this.writeByte(ENTITY_TYPE_ARRAY);
+
+ if (!value) {
+ this.writeByte(COLUMN_TYPE_NULL);
+ } else {
+ this.writeByte(COLUMN_TYPE_DOUBLE);
+ this.writeArray(value);
+ }
+ });
+ return this;
+ }
}
export { SenderBufferV2 };
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index fdac848..cd11d09 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -109,6 +109,8 @@ interface SenderBuffer {
*/
floatColumn(name: string, value: number): SenderBuffer;
+ arrayColumn(name: string, value: unknown[]): SenderBuffer;
+
/**
* Write an integer column with its value into the buffer of the sender.
*
diff --git a/src/sender.ts b/src/sender.ts
index e06df2e..a5767d9 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -234,6 +234,11 @@ class Sender {
return this;
}
+ arrayColumn(name: string, value: unknown[]): Sender {
+ this.buffer.arrayColumn(name, value);
+ return this;
+ }
+
/**
* Write an integer column with its value into the buffer of the sender.
*
diff --git a/src/utils.ts b/src/utils.ts
index 734740d..9a1b8a5 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -36,6 +36,18 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
}
}
+function getDimensions(arr: unknown) {
+ const dimensions: number[] = [];
+ while (Array.isArray(arr)) {
+ if (arr.length === 0) {
+ throw new Error("zero length array not supported");
+ }
+ dimensions.push(arr.length);
+ arr = arr[0];
+ }
+ return dimensions;
+}
+
async function fetchJson(url: string): Promise {
let response: globalThis.Response;
try {
@@ -59,4 +71,5 @@ export {
timestampToNanos,
TimestampUnit,
fetchJson,
+ getDimensions,
};
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 4f43623..3f6e2ee 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -167,6 +167,108 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
+ it("does not support arrays with protocol v1", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "1",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender.table("tableName").arrayColumn("arrayCol", [12.3, 23.4]),
+ ).toThrow("Arrays are not supported in protocol v1");
+ await sender.close();
+ });
+
+ it("supports arrays with protocol v2", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .arrayColumn("arrayCol", [12.3, 23.4])
+ .atNow();
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName arrayCol==") +
+ " 0e 0a 01 02 00 00 00 9a 99 99 99 99 99 28 40 66 66 66 66 66 66 37 40 " +
+ toHex("\n"),
+ );
+ await sender.close();
+ });
+
+ it("supports multidimensional arrays with protocol v2", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .arrayColumn("arrayCol", [[12.3], [23.4]])
+ .atNow();
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName arrayCol==") +
+ " 0e 0a 02 02 00 00 00 01 00 00 00 9a 99 99 99 99 99 28 40 66 66 66 66 66 66 37 40 " +
+ toHex("\n"),
+ );
+ await sender.close();
+ });
+
+ it("does not accept empty array", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ sender.table("tableName");
+ expect(() => sender.arrayColumn("arrayCol", [])).toThrow(
+ "zero length array not supported",
+ );
+ expect(() => sender.arrayColumn("arrayCol", [[], []])).toThrow(
+ "zero length array not supported",
+ );
+ await sender.close();
+ });
+
+ it("does not accept irregular array", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ expect(() =>
+ sender.table("tableName").arrayColumn("arrayCol", [[1.1, 2.2], [3.3]]),
+ ).toThrow(
+ "length does not match array dimensions [dimensions=[2,2], length=1]",
+ );
+ await sender.close();
+ });
+
+ it("supports arrays with NULL value", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender.table("tableName").arrayColumn("arrayCol", undefined).atNow();
+ await sender.table("tableName").arrayColumn("arrayCol", null).atNow();
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName arrayCol==") +
+ " 0e 21 " +
+ toHex("\ntableName arrayCol==") +
+ " 0e 21 " +
+ toHex("\n"),
+ );
+ await sender.close();
+ });
+
it("supports timestamp field as number", async function () {
const sender = new Sender({
protocol: "tcp",
@@ -838,6 +940,21 @@ function bufferContent(sender: Sender) {
return sender.buffer.toBufferView().toString();
}
+function bufferContentHex(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return toHexString(sender.buffer.toBufferView());
+}
+
+function toHex(str: string) {
+ return toHexString(Buffer.from(str));
+}
+
+function toHexString(buffer: Buffer) {
+ return Array.from(buffer)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join(" ");
+}
+
function bufferSize(sender: Sender) {
// @ts-expect-error - Accessing private field
return sender.buffer.bufferSize;
From 46664d007195fde96a41e9dc5b04a1460b51ad2e Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Tue, 29 Jul 2025 01:26:03 +0100
Subject: [PATCH 10/38] test unsupported array types
---
test/sender.buffer.test.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 3f6e2ee..1fd55f8 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -250,6 +250,22 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
+ it("does not accept unsupported types", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ sender.table("tableName");
+ expect(() => sender.arrayColumn("col", ['str'])).toThrow("unsupported array type [type=string]");
+ expect(() => sender.arrayColumn("col", [true])).toThrow("unsupported array type [type=boolean]");
+ expect(() => sender.arrayColumn("col", [{}])).toThrow("unsupported array type [type=object]");
+ expect(() => sender.arrayColumn("col", [null])).toThrow("unsupported array type [type=object]");
+ expect(() => sender.arrayColumn("col", [undefined])).toThrow("unsupported array type [type=undefined]");
+ await sender.close();
+ });
+
it("supports arrays with NULL value", async function () {
const sender = new Sender({
protocol: "http",
From 2cdf4e5ac29846516370ea849c27a739e1cc88b3 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 30 Jul 2025 11:36:22 +0100
Subject: [PATCH 11/38] more tests, better error message
---
src/buffer/bufferv2.ts | 4 ++--
test/sender.buffer.test.ts | 23 +++++++++++++++++++++++
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index 168d5b5..c1de5af 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -32,8 +32,8 @@ class SenderBufferV2 extends SenderBufferBase {
}
arrayColumn(name: string, value: unknown[]): SenderBuffer {
- if (value && !Array.isArray(value)) {
- throw new Error(`Value must be an array, received ${value}`);
+ if (value !== null && value !== undefined && !Array.isArray(value)) {
+ throw new Error(`The value must be an array [value=${JSON.stringify(value)}, type=${typeof value}]`);
}
this.writeColumn(name, value, () => {
this.checkCapacity([], 3);
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 1fd55f8..42c8886 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -266,6 +266,29 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
+ it("does not accept non-array types", async function () {
+ const sender = new Sender({
+ protocol: "http",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ sender.table("tableName");
+ // @ts-expect-error - Testing invalid input
+ expect(() => sender.arrayColumn("col", 12.345)).toThrow("The value must be an array [value=12.345, type=number]");
+ // @ts-expect-error - Testing invalid input
+ expect(() => sender.arrayColumn("col", 42)).toThrow("The value must be an array [value=42, type=number]");
+ // @ts-expect-error - Testing invalid input
+ expect(() => sender.arrayColumn("col", "str")).toThrow("The value must be an array [value=\"str\", type=string]");
+ // @ts-expect-error - Testing invalid input
+ expect(() => sender.arrayColumn("col", "")).toThrow("The value must be an array [value=\"\", type=string]");
+ // @ts-expect-error - Testing invalid input
+ expect(() => sender.arrayColumn("col", true)).toThrow("The value must be an array [value=true, type=boolean]");
+ // @ts-expect-error - Testing invalid input
+ expect(() => sender.arrayColumn("col", {})).toThrow("The value must be an array [value={}, type=object]");
+ await sender.close();
+ });
+
it("supports arrays with NULL value", async function () {
const sender = new Sender({
protocol: "http",
From ab6f96818fccbf2af5119ae2a1922c3eebb1cead Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 01:14:55 +0100
Subject: [PATCH 12/38] array validation
---
src/buffer/base.ts | 47 +++++++++-----
src/buffer/bufferv2.ts | 11 +++-
src/utils.ts | 71 ++++++++++++++++++--
test/sender.buffer.test.ts | 130 ++++++++++++++++++++++++++++++++-----
4 files changed, 217 insertions(+), 42 deletions(-)
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index e8c0de3..2d0ab23 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -10,8 +10,8 @@ import {
DEFAULT_MAX_BUFFER_SIZE,
} from "./index";
import {
+ ArrayPrimitive,
isInteger,
- getDimensions,
timestampToMicros,
timestampToNanos,
TimestampUnit,
@@ -390,35 +390,29 @@ abstract class SenderBufferBase implements SenderBuffer {
this.position = this.buffer.writeDoubleLE(data, this.position);
}
- protected writeArray(arr: unknown[]) {
- const dimensions = getDimensions(arr);
+ protected writeArray(
+ arr: unknown[],
+ dimensions: number[],
+ type: ArrayPrimitive,
+ ) {
this.checkCapacity([], 1 + dimensions.length * 4);
this.writeByte(dimensions.length);
- let numOfElements = 1;
for (let i = 0; i < dimensions.length; i++) {
- numOfElements *= dimensions[i];
this.writeInt(dimensions[i]);
}
- this.checkCapacity([], numOfElements * 8);
+ this.checkCapacity([], SenderBufferBase.arraySize(dimensions, type));
this.writeArrayValues(arr, dimensions);
}
private writeArrayValues(arr: unknown[], dimensions: number[]) {
if (Array.isArray(arr[0])) {
- const length = arr[0].length;
for (let i = 0; i < arr.length; i++) {
- const subArray = arr[i] as unknown[];
- if (subArray.length !== length) {
- throw new Error(
- `length does not match array dimensions [dimensions=[${dimensions}], length=${subArray.length}]`,
- );
- }
- this.writeArrayValues(subArray, dimensions);
+ this.writeArrayValues(arr[i] as unknown[], dimensions);
}
} else {
- const dataType = typeof arr[0];
- switch (dataType) {
+ const type = typeof arr[0];
+ switch (type) {
case "number":
for (let i = 0; i < arr.length; i++) {
this.position = this.buffer.writeDoubleLE(
@@ -428,7 +422,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
break;
default:
- throw new Error(`unsupported array type [type=${dataType}]`);
+ throw new Error(`Unsupported array type [type=${type}]`);
}
}
}
@@ -470,6 +464,25 @@ abstract class SenderBufferBase implements SenderBuffer {
}
}
+ private static arraySize(dimensions: number[], type: ArrayPrimitive): number {
+ let numOfElements = 1;
+ for (let i = 0; i < dimensions.length; i++) {
+ numOfElements *= dimensions[i];
+ }
+
+ switch (type) {
+ case "number":
+ return numOfElements * 8;
+ case "boolean":
+ return numOfElements;
+ case "string":
+ // in case of string[] capacity check is done separately for each array element
+ return 0;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
+
private assertBufferOverflow() {
if (this.position > this.bufferSize) {
// should never happen, if checkCapacity() is correctly used
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index c1de5af..f9fd602 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -2,6 +2,7 @@
import { SenderOptions } from "../options";
import { SenderBuffer } from "./index";
import { SenderBufferBase } from "./base";
+import { getDimensions, validateArray } from "../utils";
const COLUMN_TYPE_DOUBLE: number = 10;
const COLUMN_TYPE_NULL: number = 33;
@@ -32,9 +33,13 @@ class SenderBufferV2 extends SenderBufferBase {
}
arrayColumn(name: string, value: unknown[]): SenderBuffer {
- if (value !== null && value !== undefined && !Array.isArray(value)) {
- throw new Error(`The value must be an array [value=${JSON.stringify(value)}, type=${typeof value}]`);
+ const dimensions = getDimensions(value);
+ const type = validateArray(value, dimensions);
+ // only number arrays and NULL supported for now
+ if (type !== "number" && type !== null) {
+ throw new Error(`Unsupported array type [type=${type}]`);
}
+
this.writeColumn(name, value, () => {
this.checkCapacity([], 3);
this.writeByte(EQUALS_SIGN);
@@ -44,7 +49,7 @@ class SenderBufferV2 extends SenderBufferBase {
this.writeByte(COLUMN_TYPE_NULL);
} else {
this.writeByte(COLUMN_TYPE_DOUBLE);
- this.writeArray(value);
+ this.writeArray(value, dimensions, type);
}
});
return this;
diff --git a/src/utils.ts b/src/utils.ts
index 9a1b8a5..095bcf6 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,5 @@
+type ArrayPrimitive = "number" | "boolean" | "string" | null;
+
type TimestampUnit = "ns" | "us" | "ms";
function isBoolean(value: unknown): value is boolean {
@@ -36,18 +38,73 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
}
}
-function getDimensions(arr: unknown) {
+function getDimensions(data: unknown) {
const dimensions: number[] = [];
- while (Array.isArray(arr)) {
- if (arr.length === 0) {
- throw new Error("zero length array not supported");
+ while (Array.isArray(data)) {
+ if (data.length === 0) {
+ throw new Error("Zero length array not supported");
}
- dimensions.push(arr.length);
- arr = arr[0];
+ dimensions.push(data.length);
+ data = data[0];
}
return dimensions;
}
+function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
+ if (data === null || data === undefined) {
+ return null;
+ }
+ if (!Array.isArray(data)) {
+ throw new Error(
+ `The value must be an array [value=${JSON.stringify(data)}, type=${typeof data}]`,
+ );
+ }
+
+ let expectedType: ArrayPrimitive = null;
+
+ function checkArray(
+ array: unknown[],
+ depth: number = 0,
+ path: string = "",
+ ): void {
+ const expectedLength = dimensions[depth];
+ if (array.length !== expectedLength) {
+ throw new Error(
+ `Length of arrays do not match [expected=${expectedLength}, actual=${array.length}, dimensions=[${dimensions}], path=${path}]`,
+ );
+ }
+
+ if (depth < dimensions.length - 1) {
+ // intermediate level, expecting arrays
+ for (let i = 0; i < array.length; i++) {
+ if (!Array.isArray(array[i])) {
+ throw new Error(
+ `Mixed types found [expected=array, current=${typeof array[i]}, path=${path}[${i}]]`,
+ );
+ }
+ checkArray(array[i] as unknown[], depth + 1, `${path}[${i}]`);
+ }
+ } else {
+ // leaf level, expecting primitives
+ if (expectedType === null) {
+ expectedType = typeof array[0] as ArrayPrimitive;
+ }
+
+ for (let i = 0; i < array.length; i++) {
+ const currentType = typeof array[i] as ArrayPrimitive;
+ if (currentType !== expectedType) {
+ throw new Error(
+ `Mixed types found [expected=${expectedType}, current=${currentType}, path=${path}[${i}]]`,
+ );
+ }
+ }
+ }
+ }
+
+ checkArray(data);
+ return expectedType;
+}
+
async function fetchJson(url: string): Promise {
let response: globalThis.Response;
try {
@@ -72,4 +129,6 @@ export {
TimestampUnit,
fetchJson,
getDimensions,
+ validateArray,
+ ArrayPrimitive,
};
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 42c8886..89dfeac 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -227,15 +227,15 @@ describe("Sender message builder test suite (anything not covered in client inte
});
sender.table("tableName");
expect(() => sender.arrayColumn("arrayCol", [])).toThrow(
- "zero length array not supported",
+ "Zero length array not supported",
);
expect(() => sender.arrayColumn("arrayCol", [[], []])).toThrow(
- "zero length array not supported",
+ "Zero length array not supported",
);
await sender.close();
});
- it("does not accept irregular array", async function () {
+ it("does not accept irregularly sized array", async function () {
const sender = new Sender({
protocol: "tcp",
protocol_version: "2",
@@ -243,9 +243,85 @@ describe("Sender message builder test suite (anything not covered in client inte
init_buf_size: 1024,
});
expect(() =>
- sender.table("tableName").arrayColumn("arrayCol", [[1.1, 2.2], [3.3]]),
+ sender.table("tableName").arrayColumn("arrayCol", [
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [[1.1, 2.2], [3.3], [5.5, 6.6]],
+ ]),
).toThrow(
- "length does not match array dimensions [dimensions=[2,2], length=1]",
+ "Length of arrays do not match [expected=2, actual=1, dimensions=[4,3,2], path=[3][1]]",
+ );
+ await sender.close();
+ });
+
+ it("does not accept non-homogenous array", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ sender.table("tableName");
+ expect(() =>
+ sender.arrayColumn("arrayCol", [
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, "4.4"],
+ [5.5, 6.6],
+ ],
+ ]),
+ ).toThrow(
+ "Mixed types found [expected=number, current=string, path=[3][1][1]]",
+ );
+ expect(() =>
+ sender.arrayColumn("arrayCol", [
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [
+ [1.1, 2.2],
+ [3.3, 4.4],
+ [5.5, 6.6],
+ ],
+ [[1.1, 2.2], 3.3, [5.5, 6.6]],
+ ]),
+ ).toThrow(
+ "Mixed types found [expected=array, current=number, path=[3][1]]",
);
await sender.close();
});
@@ -258,11 +334,21 @@ describe("Sender message builder test suite (anything not covered in client inte
init_buf_size: 1024,
});
sender.table("tableName");
- expect(() => sender.arrayColumn("col", ['str'])).toThrow("unsupported array type [type=string]");
- expect(() => sender.arrayColumn("col", [true])).toThrow("unsupported array type [type=boolean]");
- expect(() => sender.arrayColumn("col", [{}])).toThrow("unsupported array type [type=object]");
- expect(() => sender.arrayColumn("col", [null])).toThrow("unsupported array type [type=object]");
- expect(() => sender.arrayColumn("col", [undefined])).toThrow("unsupported array type [type=undefined]");
+ expect(() => sender.arrayColumn("col", ["str"])).toThrow(
+ "Unsupported array type [type=string]",
+ );
+ expect(() => sender.arrayColumn("col", [true])).toThrow(
+ "Unsupported array type [type=boolean]",
+ );
+ expect(() => sender.arrayColumn("col", [{}])).toThrow(
+ "Unsupported array type [type=object]",
+ );
+ expect(() => sender.arrayColumn("col", [null])).toThrow(
+ "Unsupported array type [type=object]",
+ );
+ expect(() => sender.arrayColumn("col", [undefined])).toThrow(
+ "Unsupported array type [type=undefined]",
+ );
await sender.close();
});
@@ -275,17 +361,29 @@ describe("Sender message builder test suite (anything not covered in client inte
});
sender.table("tableName");
// @ts-expect-error - Testing invalid input
- expect(() => sender.arrayColumn("col", 12.345)).toThrow("The value must be an array [value=12.345, type=number]");
+ expect(() => sender.arrayColumn("col", 12.345)).toThrow(
+ "The value must be an array [value=12.345, type=number]",
+ );
// @ts-expect-error - Testing invalid input
- expect(() => sender.arrayColumn("col", 42)).toThrow("The value must be an array [value=42, type=number]");
+ expect(() => sender.arrayColumn("col", 42)).toThrow(
+ "The value must be an array [value=42, type=number]",
+ );
// @ts-expect-error - Testing invalid input
- expect(() => sender.arrayColumn("col", "str")).toThrow("The value must be an array [value=\"str\", type=string]");
+ expect(() => sender.arrayColumn("col", "str")).toThrow(
+ 'The value must be an array [value="str", type=string]',
+ );
// @ts-expect-error - Testing invalid input
- expect(() => sender.arrayColumn("col", "")).toThrow("The value must be an array [value=\"\", type=string]");
+ expect(() => sender.arrayColumn("col", "")).toThrow(
+ 'The value must be an array [value="", type=string]',
+ );
// @ts-expect-error - Testing invalid input
- expect(() => sender.arrayColumn("col", true)).toThrow("The value must be an array [value=true, type=boolean]");
+ expect(() => sender.arrayColumn("col", true)).toThrow(
+ "The value must be an array [value=true, type=boolean]",
+ );
// @ts-expect-error - Testing invalid input
- expect(() => sender.arrayColumn("col", {})).toThrow("The value must be an array [value={}, type=object]");
+ expect(() => sender.arrayColumn("col", {})).toThrow(
+ "The value must be an array [value={}, type=object]",
+ );
await sender.close();
});
From 094b83a6411bd9701a9894bf140f0356c49009c1 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 11:46:42 +0100
Subject: [PATCH 13/38] chore(nodejs): js doc
---
README.md | 75 +-
docs/HttpTransport.html | 766 +++++
docs/HttpTransportBase.html | 531 ++++
docs/Sender.html | 492 +--
docs/SenderBufferBase.html | 3001 ++++++++++++++++++
docs/SenderBufferV1.html | 483 +++
docs/SenderBufferV2.html | 555 ++++
docs/SenderOptions.html | 39 +-
docs/TcpTransport.html | 840 +++++
docs/UndiciTransport.html | 489 +++
docs/global.html | 1536 ++++++++-
docs/index.html | 89 +-
docs/index.js.html | 2329 +++++++++-----
docs/scripts/prettify/Apache-License-2.0.txt | 0
src/buffer/base.ts | 64 +-
src/buffer/bufferv1.ts | 18 +
src/buffer/bufferv2.ts | 24 +
src/buffer/index.ts | 25 +-
src/logging.ts | 8 +
src/options.ts | 21 +-
src/sender.ts | 48 +-
src/transport/http/base.ts | 62 +-
src/transport/http/legacy.ts | 50 +-
src/transport/http/undici.ts | 30 +-
src/transport/index.ts | 30 +
src/transport/tcp.ts | 87 +-
src/utils.ts | 44 +
27 files changed, 10510 insertions(+), 1226 deletions(-)
create mode 100644 docs/HttpTransport.html
create mode 100644 docs/HttpTransportBase.html
create mode 100644 docs/SenderBufferBase.html
create mode 100644 docs/SenderBufferV1.html
create mode 100644 docs/SenderBufferV2.html
create mode 100644 docs/TcpTransport.html
create mode 100644 docs/UndiciTransport.html
mode change 100755 => 100644 docs/scripts/prettify/Apache-License-2.0.txt
diff --git a/README.md b/README.md
index 3a52e56..d957cc3 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,10 @@ The client requires Node.js v20 or newer version.
```shell
# With npm
npm i -s @questdb/nodejs-client
+
# With yarn
yarn add @questdb/nodejs-client
+
# With pnpm
pnpm add @questdb/nodejs-client
```
@@ -38,25 +40,52 @@ For more details, please, check the {
// create a sender using HTTPS protocol with bearer token authentication
const sender: Sender = Sender.fromConfig(
- "https::addr=127.0.0.1:9000;token=Xyvd3er6GF87ysaHk;",
+ "https::addr=127.0.0.1:9000;token=Xyvd3er6GF87ysaHk",
);
// send the data over the authenticated and secure connection
diff --git a/docs/HttpTransport.html b/docs/HttpTransport.html
new file mode 100644
index 0000000..0533fcd
--- /dev/null
+++ b/docs/HttpTransport.html
@@ -0,0 +1,766 @@
+
+
+
+
+ JSDoc: Class: HttpTransport
+
+
+
+
+
+
+
+
+
+
+
+ Default number of rows that trigger auto-flush
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/Sender.html b/docs/Sender.html
index 0d8d510..049e28b 100644
--- a/docs/Sender.html
+++ b/docs/Sender.html
@@ -30,9 +30,19 @@
Class: Sender
Sender(options)
-
The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
-The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
-Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
+
The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+The client supports multiple transport protocols.
+
+Transport Options:
+
+
HTTP (http://): Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes.
+Recommended for most use cases due to superior error handling and debugging capabilities. Uses Undici library by default for high performance.
+
HTTPS (https://): Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication.
+Supports certificate validation and custom CA certificates.
TCPS (tcps://): Secure TCP transport with TLS encryption.
+
+
The client supports authentication.
Authentication details can be passed to the Sender in its configuration options.
@@ -48,19 +58,41 @@
Sender
+The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
+serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
+performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS
+by setting the protocol_version to 'auto' (default behavior).
+
+
The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server.
Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum
buffer sizes can also be set.
It is recommended that the Sender is created by using one of the static factory methods,
-Sender.fromConfig(configString, extraOptions) or Sender.fromEnv(extraOptions)).
+Sender.fromConfig(configString, extraOptions) or Sender.fromEnv(extraOptions).
If the Sender is created via its constructor, at least the SenderOptions configuration object should be
initialized from a configuration string to make sure that the parameters are validated.
Detailed description of the Sender's configuration options can be found in
the SenderOptions documentation.
+HTTP Transport Implementation:
+By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling.
+For compatibility or specific requirements, you can enable the legacy HTTP transport using Node.js built-in modules
+by setting legacy_http=on in the configuration string. The legacy transport provides the same functionality
+but uses Node.js http/https modules instead of Undici.
+
+
Extra options can be provided to the Sender in the extraOptions configuration object.
A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object.
The logger implementation provides the option to direct log messages to the same place where the host application's
@@ -188,7 +220,7 @@
+ Write an array column with its values into the buffer of the sender.
+Note: Array columns are only supported in protocol version 2. If using protocol version 1,
+this method will throw an error.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
name
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
Column name.
+
+
+
+
+
+
+
value
+
+
+
+
+
+Array.<unknown>
+
+
+
+
+
+
+
+
+
+
Array values to be written. Currently supports arrays of numbers.
- Resolves to true when there was data in the buffer to send.
+ Resolves to true when there was data in the buffer to send, and it was sent successfully.
- Extends the size of the sender's buffer.
-Can be used to increase the size of buffer if overflown.
-The buffer's content is copied into the new buffer.
-
-
-
-
-
-
-
-
-
-
-
Parameters:
-
-
-
-
-
-
-
Name
-
-
-
Type
-
-
-
-
-
-
Description
-
-
-
-
-
-
-
-
-
bufferSize
-
-
-
-
-
-number
-
-
-
-
-
-
-
-
-
-
New size of the buffer used by the sender, provided in bytes.
Abstract base class for QuestDB line protocol buffer implementations.
+Provides common functionality for building line protocol messages including
+table names, symbols, columns, and timestamps.
+
+
+
+
+
+
+
+
+
+
+
Constructor
+
+
+
+
new SenderBufferBase(options)
+
+
+
+
+
+
+
+ Creates an instance of SenderBufferBase.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+
+
+
+
+
+
Sender configuration object containing buffer and naming options
+ Closing the row without writing designated timestamp into the buffer of the sender.
+Designated timestamp will be populated by the server on this record.
+
+ Resets the buffer, data added to the buffer will be lost.
+In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+
+ Extends the size of the sender's buffer.
+Can be used to increase the size of buffer if overflown.
+The buffer's content is copied into the new buffer.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
bufferSize
+
+
+
+
+
+
+
+
+
+
+
New size of the buffer used by the sender, provided in bytes
+ Reference to this sender buffer for method chaining
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/SenderOptions.html b/docs/SenderOptions.html
index 6bfe165..050fc2e 100644
--- a/docs/SenderOptions.html
+++ b/docs/SenderOptions.html
@@ -35,13 +35,18 @@
SenderOptio
Properties of the object are initialized through a configuration string.
The configuration string has the following format: <protocol>::<key>=<value><key>=<value>...;
The keys are case-sensitive, the trailing semicolon is optional.
-The values are validated, and an error is thrown if the format is invalid.
+The values are validated and an error is thrown if the format is invalid.
Connection and protocol options
protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
When https or tcps used, the connection is secured with TLS encryption.
+
protocol_version: enum, accepted values: auto, 1, 2 - The line protocol version used for data serialization.
+Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and supports array columns.
+When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
+TCP/TCPS connections default to version 1. Version 2 provides better performance for numeric data and enables array column support.
+
addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
If no port is specified, a default will be used.
When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009.
@@ -67,8 +72,8 @@
SenderOptio
TLS options
tls_verify: enum, accepted values: on, unsafe_off - When the HTTPS or TCPS protocols are selected, TLS encryption is used.
-By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to off. This is useful
-non-production environments where self-signed certificates might be used, but should be avoided in production if possible.
+By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to unsafe_off.
+This is useful in non-production environments where self-signed certificates might be used, but should be avoided in production if possible.
tls_ca: string - Path to a file containing the root CA's certificate in PEM format.
Can be useful when self-signed certificates are used, otherwise should not be set.
@@ -80,9 +85,9 @@
SenderOptio
auto_flush: enum, accepted values: on, off - The Sender automatically flushes the buffer by default. This can be switched off
by setting this option to off.
When disabled, the flush() method of the Sender has to be called explicitly to make sure data is sent to the server.
-Manual buffer flushing can be useful, especially when we want to use transactions. When the HTTP protocol is used, each flush results in a single HTTP
-request, which becomes a single transaction on the server side. The transaction either succeeds, and all rows sent in the request are
-inserted; or it fails, and none of the rows make it into the database.
+Manual buffer flushing can be useful, especially when we want to control transaction boundaries.
+When the HTTP protocol is used, each flush results in a single HTTP request, which becomes a single transaction on the server side.
+The transaction either succeeds, and all rows sent in the request are inserted; or it fails, and none of the rows make it into the database.
auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol.
@@ -121,11 +126,6 @@
SenderOptio
max_name_len: integer - The maximum length of a table or column name, the Sender defaults this parameter to 127.
Recommended to use the same setting as the server, which also uses 127 by default.
-
copy_buffer: enum, accepted values: on, off - By default, the Sender creates a new buffer for every flush() call,
-and the data to be sent to the server is copied into this new buffer.
-Setting the flag to off results in reusing the same buffer instance for each flush() call.
-Use this flag only if calls to the client are serialised.
-
@@ -228,7 +228,8 @@
Parameters:
- 'log' is a logging function used by the Sender.
Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
- 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
-A http.Agent or https.Agent object is expected.
+An undici.Agent object is expected.
+If the legacy HTTP transport is used, a http.Agent or https.Agent object is expected.
@@ -269,7 +270,7 @@
TCP transport implementation for QuestDB line protocol.
+Supports both TCP and TCPS (TLS-encrypted) connections with JWK token authentication.
+Provides persistent connections with challenge-response authentication flow.
+
+
+
+
+
+
+
+
+
+
+
Constructor
+
+
+
+
new TcpTransport(options)
+
+
+
+
+
+
+
+ Creates a new TcpTransport instance.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+
+
+
+
+
+
Sender configuration object containing connection and authentication details
+
+ Error if the connection is not established
+
+
+
+
+
+
+
+
Returns:
+
+
+
+ Promise resolving to true if data was sent successfully
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/UndiciTransport.html b/docs/UndiciTransport.html
new file mode 100644
index 0000000..edb116b
--- /dev/null
+++ b/docs/UndiciTransport.html
@@ -0,0 +1,489 @@
+
+
+
+
+ JSDoc: Class: UndiciTransport
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: UndiciTransport
+
+
+
+
+
+
+
+
+
+
+
UndiciTransport(options)
+
+
HTTP transport implementation using the Undici library.
+Provides high-performance HTTP requests with connection pooling and retry logic.
+Supports both HTTP and HTTPS protocols with configurable authentication.
+
+
+
+
+
+
+
+
+
+
+
Constructor
+
+
+
+
new UndiciTransport(options)
+
+
+
+
+
+
+
+ Creates a new UndiciTransport instance.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+
+
+
+
+
+
Sender configuration object containing connection and retry settings
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/global.html b/docs/global.html
index 09db35e..e75fd6a 100644
--- a/docs/global.html
+++ b/docs/global.html
@@ -98,22 +98,1307 @@
Members
-
(constant) DEFAULT_HTTP_OPTIONS :Agent.Options
+
(constant) DEFAULT_HTTP_AGENT_CONFIG
+
+ Default configuration for HTTP agents.
+- Persistent connections with 1 minute idle timeout
+- Maximum of 256 open connections (matching server default)
+
+ True if the status code indicates a retryable error
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
log(level, message)
+
+
+
+
+
+
+
+ Simple logger to write log messages to the console.
+Supported logging levels are `error`, `warn`, `info` and `debug`.
+Throws an error if logging level is invalid.
+
log
- Simple logger to write log messages to the console.
-Supported logging levels are `error`, `warn`, `info` and `debug`.
-Throws an error if logging level is invalid.
+ Converts a timestamp from the specified unit to microseconds.
@@ -222,24 +1513,28 @@
# With npm
npm i -s @questdb/nodejs-client
+
# With yarn
yarn add @questdb/nodejs-client
+
# With pnpm
pnpm add @questdb/nodejs-client
@@ -65,12 +67,12 @@
Compatibility table
-
^5.0.0
+
^4.0.0
>=v20.X.X
Undici Agent
-
^4.0.0
+
^3.0.0
<v20.X.X
Http.Agent
@@ -86,36 +88,64 @@
Basic API usage
import { Sender } from "@questdb/nodejs-client";
async function run() {
- // create a sender using HTTP protocol
- const sender = Sender.fromConfig("http::addr=127.0.0.1:9000");
-
- // add rows to the buffer of the sender
- await sender
- .table("trades")
- .symbol("symbol", "ETH-USD")
- .symbol("side", "sell")
- .floatColumn("price", 2615.54)
- .floatColumn("amount", 0.00044)
- .at(Date.now(), "ms");
-
- // flush the buffer of the sender, sending the data to QuestDB
- // the buffer is cleared after the data is sent, and the sender is ready to accept new data
- await sender.flush();
-
- // close the connection after all rows ingested
- // unflushed data will be lost
- await sender.close();
+ // create a sender with protocol version 2 to support arrays
+ const sender = await Sender.fromConfig('http::addr=localhost:9000;protocol_version=2');
+
+ // order book snapshots
+ const orderBooks = [
+ {
+ symbol: 'BTC-USD',
+ exchange: 'COINBASE',
+ timestamp: Date.now(),
+ bidPrices: [50100.25, 50100.20, 50100.15, 50100.10, 50100.05],
+ bidSizes: [0.5, 1.2, 2.1, 0.8, 3.5],
+ askPrices: [50100.30, 50100.35, 50100.40, 50100.45, 50100.50],
+ askSizes: [0.6, 1.5, 1.8, 2.2, 4.0]
+ },
+ {
+ symbol: 'ETH-USD',
+ exchange: 'COINBASE',
+ timestamp: Date.now(),
+ bidPrices: [2850.50, 2850.45, 2850.40, 2850.35, 2850.30],
+ bidSizes: [5.0, 8.2, 12.5, 6.8, 15.0],
+ askPrices: [2850.55, 2850.60, 2850.65, 2850.70, 2850.75],
+ askSizes: [4.5, 7.8, 10.2, 8.5, 20.0]
+ }
+ ];
+
+ try {
+ // add rows to the buffer of the sender
+ for (const orderBook of orderBooks) {
+ await sender
+ .table('order_book_l2')
+ .symbol('symbol', orderBook.symbol)
+ .symbol('exchange', orderBook.exchange)
+ .arrayColumn('bid_prices', orderBook.bidPrices)
+ .arrayColumn('bid_sizes', orderBook.bidSizes)
+ .arrayColumn('ask_prices', orderBook.askPrices)
+ .arrayColumn('ask_sizes', orderBook.askSizes)
+ .at(orderBook.timestamp, 'ms');
+ }
+
+ // flush the buffer of the sender, sending the data to QuestDB
+ // the buffer is cleared after the data is sent, and the sender is ready to accept new data
+ await sender.flush();
+ } finally {
+ // close the connection after all rows ingested
+ await sender.close();
+ }
}
run().then(console.log).catch(console.error);
Authentication and secure connection
+
Username and password authentication
import { Sender } from "@questdb/nodejs-client";
async function run() {
// create a sender using HTTPS protocol with username and password authentication
const sender = Sender.fromConfig(
- "https::addr=127.0.0.1:9000;username=admin;password=quest;",
+ "https::addr=127.0.0.1:9000;username=admin;password=quest",
);
// send the data over the authenticated and secure connection
@@ -134,13 +164,13 @@
Authentication and secure connection
run().catch(console.error);
-
TypeScript example
+
Token authentication
import { Sender } from "@questdb/nodejs-client";
async function run(): Promise<void> {
// create a sender using HTTPS protocol with bearer token authentication
const sender: Sender = Sender.fromConfig(
- "https::addr=127.0.0.1:9000;token=Xyvd3er6GF87ysaHk;",
+ "https::addr=127.0.0.1:9000;token=Xyvd3er6GF87ysaHk",
);
// send the data over the authenticated and secure connection
@@ -244,7 +274,12 @@
Worker threads example
}
run().then(console.log).catch(console.error);
-
+
+
Community
+
If you need help, have additional questions or want to provide feedback, you
+may find us on our Community Forum.
var undici = require('undici');
+var http = require('http');
+var https = require('https');
var node_buffer = require('node:buffer');
+var node_fs = require('node:fs');
var net = require('node:net');
var tls = require('node:tls');
var crypto = require('node:crypto');
-var undici = require('undici');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
+var http__default = /*#__PURE__*/_interopDefault(http);
+var https__default = /*#__PURE__*/_interopDefault(https);
var net__default = /*#__PURE__*/_interopDefault(net);
var tls__default = /*#__PURE__*/_interopDefault(tls);
var crypto__default = /*#__PURE__*/_interopDefault(crypto);
+// Log level configuration with console methods and criticality levels. <br>
+// Higher criticality values indicate more important messages.
const LOG_LEVELS = {
error: {
log: console.error,
@@ -57,6 +63,7 @@
Source: index.js
criticality: 0
}
};
+// Default logging criticality level. Messages with criticality below this level are ignored.
const DEFAULT_CRITICALITY = LOG_LEVELS.info.criticality;
/**
* Simple logger to write log messages to the console. <br>
@@ -64,7 +71,7 @@
Source: index.js
* Throws an error if logging level is invalid.
*
* @param {'error'|'warn'|'info'|'debug'} level - The log level of the message.
- * @param {string} message - The log message.
+ * @param {string | Error} message - The log message.
*/ function log(level, message) {
const logLevel = LOG_LEVELS[level];
if (!logLevel) {
@@ -76,118 +83,128 @@
Source: index.js
}
/**
- * Validates a table name. <br>
- * Throws an error if table name is invalid.
- *
- * @param {string} name - The table name to validate.
- * @param {number} maxNameLength - The maximum length of table names.
- */ function validateTableName(name, maxNameLength) {
- const len = name.length;
- if (len > maxNameLength) {
- throw new Error(`Table name is too long, max length is ${maxNameLength}`);
+ * Supported primitive types for QuestDB arrays.
+ */ /**
+ * Type guard to check if a value is a boolean.
+ * @param value - The value to check
+ * @returns True if the value is a boolean, false otherwise
+ */ function isBoolean(value) {
+ return typeof value === "boolean";
+}
+/**
+ * Type guard to check if a value is an integer within specified bounds.
+ * @param value - The value to check
+ * @param lowerBound - The minimum allowed value (inclusive)
+ * @returns True if the value is an integer >= lowerBound, false otherwise
+ */ function isInteger(value, lowerBound) {
+ return typeof value === "number" && Number.isInteger(value) && value >= lowerBound;
+}
+/**
+ * Converts a timestamp from the specified unit to microseconds.
+ * @param timestamp - The timestamp value as a bigint
+ * @param unit - The source timestamp unit
+ * @returns The timestamp converted to microseconds
+ * @throws Error if the timestamp unit is unknown
+ */ function timestampToMicros(timestamp, unit) {
+ switch(unit){
+ case "ns":
+ return timestamp / 1000n;
+ case "us":
+ return timestamp;
+ case "ms":
+ return timestamp * 1000n;
+ default:
+ throw new Error(`Unknown timestamp unit: ${unit}`);
}
- if (len === 0) {
- throw new Error("Empty string is not allowed as table name");
+}
+/**
+ * Converts a timestamp from the specified unit to nanoseconds.
+ * @param timestamp - The timestamp value as a bigint
+ * @param unit - The source timestamp unit
+ * @returns The timestamp converted to nanoseconds
+ * @throws Error if the timestamp unit is unknown
+ */ function timestampToNanos(timestamp, unit) {
+ switch(unit){
+ case "ns":
+ return timestamp;
+ case "us":
+ return timestamp * 1000n;
+ case "ms":
+ return timestamp * 1000_000n;
+ default:
+ throw new Error(`Unknown timestamp unit: ${unit}`);
}
- for(let i = 0; i < len; i++){
- const ch = name[i];
- switch(ch){
- case ".":
- if (i === 0 || i === len - 1 || name[i - 1] === ".") // single dot is allowed in the middle only
- // starting with a dot hides directory in Linux
- // ending with a dot can be trimmed by some Windows versions / file systems
- // double or triple dot looks suspicious
- // single dot allowed as compatibility,
- // when someone uploads 'file_name.csv' the file name used as the table name
- throw new Error("Table name cannot start or end with a dot, and only a single dot allowed");
- break;
- case "?":
- case ",":
- case "'":
- case '"':
- case "\\":
- case "/":
- case ":":
- case ")":
- case "(":
- case "+":
- case "*":
- case "%":
- case "~":
- case "\u0000":
- case "\u0001":
- case "\u0002":
- case "\u0003":
- case "\u0004":
- case "\u0005":
- case "\u0006":
- case "\u0007":
- case "\u0008":
- case "\u0009":
- case "\u000B":
- case "\u000c":
- case "\r":
- case "\n":
- case "\u000e":
- case "\u000f":
- case "\u007f":
- case "\ufeff":
- throw new Error(`Invalid character in table name: ${ch}`);
+}
+/**
+ * Analyzes the dimensions of a nested array structure.
+ * @param data - The array to analyze
+ * @returns Array of dimension sizes at each nesting level
+ * @throws Error if any dimension has zero length
+ */ function getDimensions(data) {
+ const dimensions = [];
+ while(Array.isArray(data)){
+ if (data.length === 0) {
+ throw new Error("Zero length array not supported");
}
+ dimensions.push(data.length);
+ data = data[0];
}
+ return dimensions;
}
-/**
- * Validates a column name. <br>
- * Throws an error if column name is invalid.
- *
- * @param {string} name - The column name to validate.
- * @param {number} maxNameLength - The maximum length of column names.
- */ function validateColumnName(name, maxNameLength) {
- const len = name.length;
- if (len > maxNameLength) {
- throw new Error(`Column name is too long, max length is ${maxNameLength}`);
+function validateArray(data, dimensions) {
+ if (data === null || data === undefined) {
+ return null;
}
- if (len === 0) {
- throw new Error("Empty string is not allowed as column name");
+ if (!Array.isArray(data)) {
+ throw new Error(`The value must be an array [value=${JSON.stringify(data)}, type=${typeof data}]`);
}
- for (const ch of name){
- switch(ch){
- case "?":
- case ".":
- case ",":
- case "'":
- case '"':
- case "\\":
- case "/":
- case ":":
- case ")":
- case "(":
- case "+":
- case "-":
- case "*":
- case "%":
- case "~":
- case "\u0000":
- case "\u0001":
- case "\u0002":
- case "\u0003":
- case "\u0004":
- case "\u0005":
- case "\u0006":
- case "\u0007":
- case "\u0008":
- case "\u0009":
- case "\u000B":
- case "\u000c":
- case "\r":
- case "\n":
- case "\u000e":
- case "\u000f":
- case "\u007f":
- case "\ufeff":
- throw new Error(`Invalid character in column name: ${ch}`);
+ let expectedType = null;
+ function checkArray(array, depth = 0, path = "") {
+ const expectedLength = dimensions[depth];
+ if (array.length !== expectedLength) {
+ throw new Error(`Length of arrays do not match [expected=${expectedLength}, actual=${array.length}, dimensions=[${dimensions}], path=${path}]`);
+ }
+ if (depth < dimensions.length - 1) {
+ // intermediate level, expecting arrays
+ for(let i = 0; i < array.length; i++){
+ if (!Array.isArray(array[i])) {
+ throw new Error(`Mixed types found [expected=array, current=${typeof array[i]}, path=${path}[${i}]]`);
+ }
+ checkArray(array[i], depth + 1, `${path}[${i}]`);
+ }
+ } else {
+ // leaf level, expecting primitives
+ if (expectedType === null) {
+ expectedType = typeof array[0];
+ }
+ for(let i = 0; i < array.length; i++){
+ const currentType = typeof array[i];
+ if (currentType !== expectedType) {
+ throw new Error(`Mixed types found [expected=${expectedType}, current=${currentType}, path=${path}[${i}]]`);
+ }
+ }
}
}
+ checkArray(data);
+ return expectedType;
+}
+/**
+ * Fetches JSON data from a URL with error handling.
+ * @template T - The expected type of the JSON response
+ * @param url - The URL to fetch from
+ * @returns Promise resolving to the parsed JSON data
+ * @throws Error if the request fails or returns a non-OK status
+ */ async function fetchJson(url) {
+ let response;
+ try {
+ response = await fetch(url);
+ } catch (error) {
+ throw new Error(`Failed to load ${url} [error=${error}]`);
+ }
+ if (!response.ok) {
+ throw new Error(`Failed to load ${url} [statusCode=${response.status} (${response.statusText})]`);
+ }
+ return await response.json();
}
const HTTP_PORT = 9000;
@@ -199,19 +216,28 @@
Source: index.js
const ON = "on";
const OFF = "off";
const UNSAFE_OFF = "unsafe_off";
+const PROTOCOL_VERSION_AUTO = "auto";
+const PROTOCOL_VERSION_V1 = "1";
+const PROTOCOL_VERSION_V2 = "2";
+const LINE_PROTO_SUPPORT_VERSION = "line.proto.support.versions";
/** @classdesc
* <a href="Sender.html">Sender</a> configuration options. <br>
* <br>
* Properties of the object are initialized through a configuration string. <br>
* The configuration string has the following format: <i><protocol>::<key>=<value><key>=<value>...;</i> <br>
* The keys are case-sensitive, the trailing semicolon is optional. <br>
- * The values are validated, and an error is thrown if the format is invalid. <br>
+ * The values are validated and an error is thrown if the format is invalid. <br>
* <br>
* Connection and protocol options
* <ul>
* <li> <b>protocol</b>: <i>enum, accepted values: http, https, tcp, tcps</i> - The protocol used to communicate with the server. <br>
* When <i>https</i> or <i>tcps</i> used, the connection is secured with TLS encryption.
* </li>
+ * <li> <b>protocol_version</b>: <i>enum, accepted values: auto, 1, 2</i> - The line protocol version used for data serialization. <br>
+ * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and supports array columns. <br>
+ * When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server. <br>
+ * TCP/TCPS connections default to version 1. Version 2 provides better performance for numeric data and enables array column support.
+ * </li>
* <li> addr: <i>string</i> - Hostname and port, separated by colon. This key is mandatory, but the port part is optional. <br>
* If no port is specified, a default will be used. <br>
* When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009. <br>
@@ -237,8 +263,8 @@
Source: index.js
* TLS options
* <ul>
* <li> tls_verify: <i>enum, accepted values: on, unsafe_off</i> - When the HTTPS or TCPS protocols are selected, TLS encryption is used. <br>
- * By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to <i>off</i>. This is useful
- * non-production environments where self-signed certificates might be used, but should be avoided in production if possible.
+ * By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to <i>unsafe_off</i>. <br>
+ * This is useful in non-production environments where self-signed certificates might be used, but should be avoided in production if possible.
* </li>
* <li> tls_ca: <i>string</i> - Path to a file containing the root CA's certificate in PEM format. <br>
* Can be useful when self-signed certificates are used, otherwise should not be set.
@@ -250,9 +276,9 @@
Source: index.js
* <li> auto_flush: <i>enum, accepted values: on, off</i> - The Sender automatically flushes the buffer by default. This can be switched off
* by setting this option to <i>off</i>. <br>
* When disabled, the flush() method of the Sender has to be called explicitly to make sure data is sent to the server. <br>
- * Manual buffer flushing can be useful, especially when we want to use transactions. When the HTTP protocol is used, each flush results in a single HTTP
- * request, which becomes a single transaction on the server side. The transaction either succeeds, and all rows sent in the request are
- * inserted; or it fails, and none of the rows make it into the database.
+ * Manual buffer flushing can be useful, especially when we want to control transaction boundaries. <br>
+ * When the HTTP protocol is used, each flush results in a single HTTP request, which becomes a single transaction on the server side. <br>
+ * The transaction either succeeds, and all rows sent in the request are inserted; or it fails, and none of the rows make it into the database.
* </li>
* <li> auto_flush_rows: <i>integer</i> - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled. <br>
* The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol.
@@ -291,11 +317,6 @@
Source: index.js
* <li> max_name_len: <i>integer</i> - The maximum length of a table or column name, the Sender defaults this parameter to 127. <br>
* Recommended to use the same setting as the server, which also uses 127 by default.
* </li>
- * <li> copy_buffer: <i>enum, accepted values: on, off</i> - By default, the Sender creates a new buffer for every flush() call,
- * and the data to be sent to the server is copied into this new buffer.
- * Setting the flag to <i>off</i> results in reusing the same buffer instance for each flush() call. <br>
- * Use this flag only if calls to the client are serialised.
- * </li>
* </ul>
*/ class SenderOptions {
/**
@@ -306,20 +327,60 @@
Source: index.js
* - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
* Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
* - 'agent' is a custom http/https agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>http.Agent</i> or <i>https.Agent</i> object is expected.
- */ constructor(configurationString, extraOptions = undefined){
+ * An <i>undici.Agent</i> object is expected. <br>
+ * If the legacy HTTP transport is used, a <i>http.Agent</i> or <i>https.Agent</i> object is expected.
+ */ constructor(configurationString, extraOptions){
parseConfigurationString(this, configurationString);
if (extraOptions) {
if (extraOptions.log && typeof extraOptions.log !== "function") {
throw new Error("Invalid logging function");
}
this.log = extraOptions.log;
- if (extraOptions.agent && !(extraOptions.agent instanceof undici.Agent)) {
- throw new Error("Invalid http/https agent");
+ if (extraOptions.agent && !(extraOptions.agent instanceof undici.Agent) && !(extraOptions.agent instanceof http__default.default.Agent) && // @ts-expect-error - Not clear what the problem is, the two lines above have no issues
+ !(extraOptions.agent instanceof https__default.default.Agent)) {
+ throw new Error("Invalid HTTP agent");
}
this.agent = extraOptions.agent;
}
}
+ static async resolveAuto(options) {
+ parseProtocolVersion(options);
+ if (options.protocol_version !== PROTOCOL_VERSION_AUTO) {
+ return options;
+ }
+ const url = `${options.protocol}://${options.host}:${options.port}/settings`;
+ const settings = await fetchJson(url);
+ const supportedVersions = (settings.config[LINE_PROTO_SUPPORT_VERSION] ?? []).map((version)=>String(version));
+ if (supportedVersions.length === 0) {
+ options.protocol_version = PROTOCOL_VERSION_V1;
+ } else if (supportedVersions.includes(PROTOCOL_VERSION_V2)) {
+ options.protocol_version = PROTOCOL_VERSION_V2;
+ } else if (supportedVersions.includes(PROTOCOL_VERSION_V1)) {
+ options.protocol_version = PROTOCOL_VERSION_V1;
+ } else {
+ throw new Error("Unsupported protocol versions received from server: " + supportedVersions);
+ }
+ return options;
+ }
+ static resolveDeprecated(options, log) {
+ if (!options) {
+ return;
+ }
+ // deal with deprecated options
+ if (options.copy_buffer !== undefined) {
+ log("warn", `Option 'copy_buffer' is not supported anymore, please, remove it`);
+ options.copy_buffer = undefined;
+ }
+ if (options.copyBuffer !== undefined) {
+ log("warn", `Option 'copyBuffer' is not supported anymore, please, remove it`);
+ options.copyBuffer = undefined;
+ }
+ if (options.bufferSize !== undefined) {
+ log("warn", `Option 'bufferSize' is not supported anymore, please, replace it with 'init_buf_size'`);
+ options.init_buf_size = options.bufferSize;
+ options.bufferSize = undefined;
+ }
+ }
/**
* Creates a Sender options object by parsing the provided configuration string.
*
@@ -331,8 +392,10 @@
Source: index.js
* A <i>http.Agent</i> or <i>https.Agent</i> object is expected.
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
- */ static fromConfig(configurationString, extraOptions = undefined) {
- return new SenderOptions(configurationString, extraOptions);
+ */ static async fromConfig(configurationString, extraOptions) {
+ const options = new SenderOptions(configurationString, extraOptions);
+ await SenderOptions.resolveAuto(options);
+ return options;
}
/**
* Creates a Sender options object by parsing the configuration string set in the <b>QDB_CLIENT_CONF</b> environment variable.
@@ -344,8 +407,8 @@
Source: index.js
* A <i>http.Agent</i> or <i>https.Agent</i> object is expected.
*
* @return {SenderOptions} A Sender configuration object initialized from the <b>QDB_CLIENT_CONF</b> environment variable.
- */ static fromEnv(extraOptions = undefined) {
- return SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions);
+ */ static async fromEnv(extraOptions) {
+ return await SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions);
}
}
function parseConfigurationString(options, configString) {
@@ -354,13 +417,14 @@
Source: index.js
}
const position = parseProtocol(options, configString);
parseSettings(options, configString, position);
+ parseProtocolVersion(options);
parseAddress(options);
parseBufferSizes(options);
parseAutoFlushOptions(options);
parseTlsOptions(options);
parseRequestTimeoutOptions(options);
parseMaxNameLength(options);
- parseCopyBuffer(options);
+ parseLegacyTransport(options);
}
function parseSettings(options, configString, position) {
let index = configString.indexOf(";", position);
@@ -390,6 +454,7 @@
}
// @ts-check
-const HTTP_NO_CONTENT = 204; // success
+// HTTP status code for successful request with no content.
+const HTTP_NO_CONTENT = 204;
+// Default number of rows that trigger auto-flush for HTTP transport.
const DEFAULT_HTTP_AUTO_FLUSH_ROWS = 75000;
-const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600;
-const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
-const DEFAULT_MAX_NAME_LENGTH = 127;
-const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400; // 100 KB/sec
-const DEFAULT_REQUEST_TIMEOUT = 10000; // 10 sec
-const DEFAULT_RETRY_TIMEOUT = 10000; // 10 sec
-const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
-const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
-/** @type {Agent.Options} */ const DEFAULT_HTTP_OPTIONS = {
- connect: {
- keepAlive: true
- },
- pipelining: 1,
- keepAliveTimeout: 60000
-};
-// an arbitrary public key, not used in authentication
-// only used to construct a valid JWK token which is accepted by the crypto API
-const PUBLIC_KEY = {
- x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc",
- y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg"
-};
-/*
-We are retrying on the following response codes (copied from the Rust client):
-500: Internal Server Error
-503: Service Unavailable
-504: Gateway Timeout
-
-// Unofficial extensions
-507: Insufficient Storage
-509: Bandwidth Limit Exceeded
-523: Origin is Unreachable
-524: A Timeout Occurred
-529: Site is overloaded
-599: Network Connect Timeout Error
-*/ const RETRIABLE_STATUS_CODES = [
+// Default minimum throughput for HTTP requests (100 KB/sec).
+const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400;
+// Default request timeout in milliseconds (10 seconds).
+const DEFAULT_REQUEST_TIMEOUT = 10000;
+// Default retry timeout in milliseconds (10 seconds).
+const DEFAULT_RETRY_TIMEOUT = 10000;
+// HTTP status codes that should trigger request retries.
+// Includes server errors and gateway timeouts that may be transient.
+const RETRIABLE_STATUS_CODES = [
500,
503,
504,
@@ -582,212 +644,264 @@
Source: index.js
529,
599
];
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response. <br>
- * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
- * <p>
- * The client supports authentication. <br>
- * Authentication details can be passed to the Sender in its configuration options. <br>
- * The client supports Basic username/password and Bearer token authentication methods when used with HTTP protocol,
- * and JWK token authentication when ingesting data via TCP. <br>
- * Please, note that authentication is enabled by default in QuestDB Enterprise only. <br>
- * Details on how to configure authentication in the open source version of
- * QuestDB: {@link https://questdb.io/docs/reference/api/ilp/authenticate}
- * </p>
- * <p>
- * The client also supports TLS encryption for both, HTTP and TCP transports to provide a secure connection. <br>
- * Please, note that the open source version of QuestDB does not support TLS, and requires an external reverse-proxy,
- * such as Nginx to enable encryption.
- * </p>
- * <p>
- * The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server.
- * Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum
- * buffer sizes can also be set.
- * </p>
- * <p>
- * It is recommended that the Sender is created by using one of the static factory methods,
- * <i>Sender.fromConfig(configString, extraOptions)</i> or <i>Sender.fromEnv(extraOptions)</i>).
- * If the Sender is created via its constructor, at least the SenderOptions configuration object should be
- * initialized from a configuration string to make sure that the parameters are validated. <br>
- * Detailed description of the Sender's configuration options can be found in
- * the <a href="SenderOptions.html">SenderOptions</a> documentation.
- * </p>
- * <p>
- * Extra options can be provided to the Sender in the <i>extraOptions</i> configuration object. <br>
- * A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object. <br>
- * The logger implementation provides the option to direct log messages to the same place where the host application's
- * log is saved. The default logger writes to the console. <br>
- * The custom HTTP(S) agent option becomes handy if there is a need to modify the default options set for the
- * HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be
- * passed to the Sender with <i>keepAlive</i> set to <i>false</i>. <br>
- * For example: <i>Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})</i> <br>
- * If no custom agent is configured, the Sender will use its own agent which overrides some default values
- * of <i>undici.Agent</i>. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
- * </p>
- */ class Sender {
+/**
+ * Abstract base class for HTTP-based transport implementations. <br>
+ * Provides common configuration and functionality for HTTP and HTTPS protocols.
+ */ class HttpTransportBase {
/**
- * Creates an instance of Sender.
- *
- * @param {SenderOptions} options - Sender configuration object. <br>
- * See SenderOptions documentation for detailed description of configuration options. <br>
+ * Creates a new HttpTransportBase instance.
+ * @param options - Sender configuration options including connection and authentication details
+ * @throws Error if required protocol or host options are missing
*/ constructor(options){
if (!options || !options.protocol) {
throw new Error("The 'protocol' option is mandatory");
}
- replaceDeprecatedOptions(options);
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
this.log = typeof options.log === "function" ? options.log : log;
+ this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
+ this.tlsCA = options.tls_ca ? node_fs.readFileSync(options.tls_ca) : undefined;
+ this.username = options.username;
+ this.password = options.password;
+ this.token = options.token;
+ if (!options.port) {
+ options.port = 9000;
+ }
+ this.host = options.host;
+ this.port = options.port;
+ this.requestMinThroughput = isInteger(options.request_min_throughput, 0) ? options.request_min_throughput : DEFAULT_REQUEST_MIN_THROUGHPUT;
+ this.requestTimeout = isInteger(options.request_timeout, 1) ? options.request_timeout : DEFAULT_REQUEST_TIMEOUT;
+ this.retryTimeout = isInteger(options.retry_timeout, 0) ? options.retry_timeout : DEFAULT_RETRY_TIMEOUT;
switch(options.protocol){
case HTTP:
- this.http = true;
this.secure = false;
- this.agent = options.agent instanceof undici.Agent ? options.agent : this.getDefaultHttpAgent();
break;
case HTTPS:
- this.http = true;
- this.secure = true;
- this.agent = options.agent instanceof undici.Agent ? options.agent : this.getDefaultHttpAgent();
- break;
- case TCP:
- this.http = false;
- this.secure = false;
- break;
- case TCPS:
- this.http = false;
this.secure = true;
break;
default:
- throw new Error(`Invalid protocol: '${options.protocol}'`);
- }
- if (this.http) {
- this.username = options.username;
- this.password = options.password;
- this.token = options.token;
- if (!options.port) {
- options.port = 9000;
- }
- } else {
- if (!options.auth && !options.jwk) {
- constructAuth(options);
- }
- this.jwk = constructJwk(options);
- if (!options.port) {
- options.port = 9009;
- }
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
}
- this.host = options.host;
- this.port = options.port;
- this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
- this.tlsCA = options.tls_ca ? node_fs.readFileSync(options.tls_ca) : undefined;
- this.autoFlush = isBoolean(options.auto_flush) ? options.auto_flush : true;
- this.autoFlushRows = isInteger(options.auto_flush_rows, 0) ? options.auto_flush_rows : this.http ? DEFAULT_HTTP_AUTO_FLUSH_ROWS : DEFAULT_TCP_AUTO_FLUSH_ROWS;
- this.autoFlushInterval = isInteger(options.auto_flush_interval, 0) ? options.auto_flush_interval : DEFAULT_AUTO_FLUSH_INTERVAL;
- this.maxNameLength = isInteger(options.max_name_len, 1) ? options.max_name_len : DEFAULT_MAX_NAME_LENGTH;
- this.requestMinThroughput = isInteger(options.request_min_throughput, 0) ? options.request_min_throughput : DEFAULT_REQUEST_MIN_THROUGHPUT;
- this.requestTimeout = isInteger(options.request_timeout, 1) ? options.request_timeout : DEFAULT_REQUEST_TIMEOUT;
- this.retryTimeout = isInteger(options.retry_timeout, 0) ? options.retry_timeout : DEFAULT_RETRY_TIMEOUT;
- const noCopy = isBoolean(options.copy_buffer) && !options.copy_buffer;
- this.toBuffer = noCopy ? this.toBufferView : this.toBufferNew;
- this.doResolve = noCopy ? (resolve)=>{
- compact(this);
- resolve(true);
- } : (resolve)=>{
- resolve(true);
- };
- this.maxBufferSize = isInteger(options.max_buf_size, 1) ? options.max_buf_size : DEFAULT_MAX_BUFFER_SIZE;
- this.resize(isInteger(options.init_buf_size, 1) ? options.init_buf_size : DEFAULT_BUFFER_SIZE);
- this.reset();
}
/**
- * Creates a Sender options object by parsing the provided configuration string.
- *
- * @param {string} configurationString - Configuration string. <br>
- * @param {object} extraOptions - Optional extra configuration. <br>
- * - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
- * Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
- * - 'agent' is a custom Undici agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>undici.Agent</i> object is expected.
- *
- * @return {Sender} A Sender object initialized from the provided configuration string.
- */ static fromConfig(configurationString, extraOptions = undefined) {
- return new Sender(SenderOptions.fromConfig(configurationString, extraOptions));
+ * HTTP transport does not require explicit connection establishment.
+ * @throws Error indicating connect is not required for HTTP transport
+ */ connect() {
+ throw new Error("'connect()' is not required for HTTP transport");
}
/**
- * Creates a Sender options object by parsing the configuration string set in the <b>QDB_CLIENT_CONF</b> environment variable.
- *
- * @param {object} extraOptions - Optional extra configuration. <br>
- * - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
- * Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
- * - 'agent' is a custom Undici agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>undici.Agent</i> object is expected.
- *
- * @return {Sender} A Sender object initialized from the <b>QDB_CLIENT_CONF</b> environment variable.
- */ static fromEnv(extraOptions = undefined) {
- return new Sender(SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions));
+ * HTTP transport does not require explicit connection closure.
+ * @returns Promise that resolves immediately
+ */ async close() {}
+ /**
+ * Gets the default auto-flush row count for HTTP transport.
+ * @returns Default number of rows that trigger auto-flush
+ */ getDefaultAutoFlushRows() {
+ return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
}
+}
+
+// @ts-check
+/**
+ * Default HTTP options for the Undici agent.
+ * Configures keep-alive connections with 60-second timeout and single request pipelining.
+ */ const DEFAULT_HTTP_OPTIONS = {
+ connect: {
+ keepAlive: true
+ },
+ pipelining: 1,
+ keepAliveTimeout: 60000
+};
+/**
+ * HTTP transport implementation using the Undici library. <br>
+ * Provides high-performance HTTP requests with connection pooling and retry logic. <br>
+ * Supports both HTTP and HTTPS protocols with configurable authentication.
+ */ class UndiciTransport extends HttpTransportBase {
/**
- * Extends the size of the sender's buffer. <br>
- * Can be used to increase the size of buffer if overflown.
- * The buffer's content is copied into the new buffer.
+ * Creates a new UndiciTransport instance.
*
- * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
- */ resize(bufferSize) {
- if (bufferSize > this.maxBufferSize) {
- throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
+ * @param options - Sender configuration object containing connection and retry settings
+ * @throws Error if the protocol is not 'http' or 'https'
+ */ constructor(options){
+ super(options);
+ switch(options.protocol){
+ case HTTP:
+ this.agent = options.agent instanceof undici.Agent ? options.agent : UndiciTransport.getDefaultHttpAgent();
+ break;
+ case HTTPS:
+ if (options.agent instanceof undici.Agent) {
+ this.agent = options.agent;
+ } else {
+ // Create a new agent with instance-specific TLS options
+ this.agent = new undici.Agent({
+ ...DEFAULT_HTTP_OPTIONS,
+ connect: {
+ ...DEFAULT_HTTP_OPTIONS.connect,
+ requestCert: this.tlsVerify,
+ rejectUnauthorized: this.tlsVerify,
+ ca: this.tlsCA
+ }
+ });
+ }
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the Undici HTTP transport");
}
- this.bufferSize = bufferSize;
- // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
- // longer than the size of the buffer. It simply just writes whatever it can, and returns.
- // If we can write into the extra byte, that indicates buffer overflow.
- // See the check in our write() function.
- const newBuffer = node_buffer.Buffer.alloc(this.bufferSize + 1, 0, "utf8");
- if (this.buffer) {
- this.buffer.copy(newBuffer);
+ this.dispatcher = new undici.RetryAgent(this.agent, {
+ maxRetries: Infinity,
+ minTimeout: 10,
+ maxTimeout: 1000,
+ timeoutFactor: 2,
+ retryAfter: true,
+ methods: [
+ "GET",
+ "POST",
+ "PUT",
+ "DELETE",
+ "PATCH",
+ "OPTIONS",
+ "HEAD"
+ ],
+ statusCodes: RETRIABLE_STATUS_CODES,
+ errorCodes: [
+ "ECONNRESET",
+ "EAI_AGAIN",
+ "ECONNREFUSED",
+ "ETIMEDOUT",
+ "EPIPE",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_BODY_TIMEOUT"
+ ]
+ });
+ }
+ /**
+ * Sends data to QuestDB using HTTP POST with retry logic and authentication.
+ * @param data - Buffer containing line protocol data to send
+ * @returns Promise resolving to true if data was sent successfully
+ * @throws Error if request fails after all retries or times out
+ */ async send(data) {
+ const headers = {};
+ if (this.token) {
+ headers["Authorization"] = `Bearer ${this.token}`;
+ } else if (this.username && this.password) {
+ headers["Authorization"] = `Basic ${node_buffer.Buffer.from(`${this.username}:${this.password}`).toString("base64")}`;
+ }
+ const controller = new AbortController();
+ const { signal } = controller;
+ setTimeout(()=>controller.abort(), this.retryTimeout);
+ let responseData;
+ try {
+ const timeoutMillis = data.length / this.requestMinThroughput * 1000 + this.requestTimeout;
+ responseData = await this.dispatcher.request({
+ origin: `${this.secure ? "https" : "http"}://${this.host}:${this.port}`,
+ path: "/write?precision=n",
+ method: "POST",
+ headers,
+ body: data,
+ headersTimeout: this.requestTimeout,
+ bodyTimeout: timeoutMillis,
+ signal
+ });
+ } catch (err) {
+ if (err.name === "AbortError") {
+ throw new Error("HTTP request timeout, no response from server in time");
+ } else {
+ throw err;
+ }
+ }
+ const { statusCode } = responseData;
+ const body = await responseData.body.arrayBuffer();
+ if (statusCode === HTTP_NO_CONTENT) {
+ if (body.byteLength > 0) {
+ this.log("warn", `Unexpected message from server: ${node_buffer.Buffer.from(body).toString()}`);
+ }
+ return true;
+ } else {
+ throw new Error(`HTTP request failed, statusCode=${statusCode}, error=${node_buffer.Buffer.from(body).toString()}`);
}
- this.buffer = newBuffer;
}
/**
- * Resets the buffer, data added to the buffer will be lost. <br>
- * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
- *
- * @return {Sender} Returns with a reference to this sender.
- */ reset() {
- this.position = 0;
- this.lastFlushTime = Date.now();
- this.pendingRowCount = 0;
- startNewRow(this);
- return this;
+ * Gets or creates the default HTTP agent with standard configuration.
+ * Uses a singleton pattern to reuse the same agent across instances.
+ * @returns The default Undici agent instance
+ */ static getDefaultHttpAgent() {
+ if (!UndiciTransport.DEFAULT_HTTP_AGENT) {
+ UndiciTransport.DEFAULT_HTTP_AGENT = new undici.Agent(DEFAULT_HTTP_OPTIONS);
+ }
+ return UndiciTransport.DEFAULT_HTTP_AGENT;
}
+}
+
+// @ts-check
+// Default number of rows that trigger auto-flush for TCP transport.
+const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600;
+// Arbitrary public key coordinates used to construct valid JWK tokens.
+// These are not used for actual authentication, only for crypto API compatibility.
+const PUBLIC_KEY = {
+ x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc",
+ y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg"
+};
+/**
+ * TCP transport implementation for QuestDB line protocol.
+ * Supports both TCP and TCPS (TLS-encrypted) connections with JWK token authentication.
+ * Provides persistent connections with challenge-response authentication flow.
+ */ class TcpTransport {
/**
- * Creates a TCP connection to the database.
- *
- * @param {net.NetConnectOpts | tls.ConnectionOptions} connectOptions - Connection options, host and port are required.
+ * Creates a new TcpTransport instance.
*
- * @return {Promise<boolean>} Resolves to true if the client is connected.
- */ connect(connectOptions = undefined) {
- if (this.http) {
- throw new Error("'connect()' should be called only if the sender connects via TCP");
+ * @param options - Sender configuration object containing connection and authentication details
+ * @throws Error if required options are missing or protocol is not 'tcp' or 'tcps'
+ */ constructor(options){
+ if (!options || !options.protocol) {
+ throw new Error("The 'protocol' option is mandatory");
}
- if (!connectOptions) {
- connectOptions = {
- host: this.host,
- port: this.port,
- ca: this.tlsCA
- };
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
+ this.log = typeof options.log === "function" ? options.log : log;
+ this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
+ this.tlsCA = options.tls_ca ? node_fs.readFileSync(options.tls_ca) : undefined;
+ this.host = options.host;
+ this.port = options.port;
+ switch(options.protocol){
+ case TCP:
+ this.secure = false;
+ break;
+ case TCPS:
+ this.secure = true;
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'tcp' or 'tcps' for the TCP transport");
}
- if (!connectOptions.host) {
- throw new Error("Hostname is not set");
+ if (!options.auth && !options.jwk) {
+ constructAuth(options);
}
- if (!connectOptions.port) {
- throw new Error("Port is not set");
+ this.jwk = constructJwk(options);
+ if (!options.port) {
+ options.port = 9009;
}
+ }
+ /**
+ * Creates a TCP connection to the database with optional authentication. <br>
+ * Handles both plain TCP and TLS-encrypted connections.
+ *
+ * @returns Promise resolving to true if the connection is established successfully
+ * @throws Error if connection fails or authentication is rejected
+ */ connect() {
+ const connOptions = {
+ host: this.host,
+ port: this.port,
+ ca: this.tlsCA
+ };
return new Promise((resolve, reject)=>{
if (this.socket) {
throw new Error("Sender connected already");
}
let authenticated = false;
let data;
- this.socket = !this.secure ? net__default.default.connect(connectOptions) : tls__default.default.connect(connectOptions, ()=>{
+ this.socket = !this.secure ? net__default.default.connect(connOptions) : tls__default.default.connect(connOptions, ()=>{
if (authenticated) {
resolve(true);
}
@@ -799,7 +913,7 @@
Source: index.js
raw
]);
if (!authenticated) {
- authenticated = await authenticate(this, data);
+ authenticated = await this.authenticate(data);
if (authenticated) {
resolve(true);
}
@@ -807,14 +921,10 @@
Source: index.js
this.log("warn", `Received unexpected data: ${data}`);
}
}).on("ready", async ()=>{
- this.log("info", `Successfully connected to ${connectOptions.host}:${connectOptions.port}`);
+ this.log("info", `Successfully connected to ${connOptions.host}:${connOptions.port}`);
if (this.jwk) {
- this.log("info", `Authenticating with ${connectOptions.host}:${connectOptions.port}`);
- await this.socket.write(`${this.jwk.kid}\n`, (err)=>{
- if (err) {
- reject(err);
- }
- });
+ this.log("info", `Authenticating with ${connOptions.host}:${connOptions.port}`);
+ this.socket.write(`${this.jwk.kid}\n`, (err)=>err ? reject(err) : ()=>{});
} else {
authenticated = true;
if (!this.secure || !this.tlsVerify) {
@@ -823,73 +933,1085 @@
Source: index.js
}
}).on("error", (err)=>{
this.log("error", err);
- if (err.code !== "SELF_SIGNED_CERT_IN_CHAIN" || this.tlsVerify) {
+ if (this.tlsVerify || !err.code || err.code !== "SELF_SIGNED_CERT_IN_CHAIN") {
+ reject(err);
+ }
+ });
+ });
+ }
+ /**
+ * Sends data over the established TCP connection.
+ * @param data - Buffer containing the data to send
+ * @returns Promise resolving to true if data was sent successfully
+ * @throws Error if the connection is not established
+ */ send(data) {
+ if (!this.socket || this.socket.destroyed) {
+ throw new Error("TCP transport is not connected");
+ }
+ return new Promise((resolve, reject)=>{
+ this.socket.write(data, (err)=>{
+ if (err) {
+ reject(err);
+ } else {
+ resolve(true);
+ }
+ });
+ });
+ }
+ /**
+ * Closes the TCP connection to the database. <br>
+ * Data sitting in the Sender's buffer will be lost unless flush() is called before close().
+ * @returns Promise that resolves when the connection is closed
+ */ async close() {
+ if (this.socket) {
+ const address = this.socket.remoteAddress;
+ const port = this.socket.remotePort;
+ this.socket.destroy();
+ this.socket = null;
+ this.log("info", `Connection to ${address}:${port} is closed`);
+ }
+ }
+ /**
+ * Gets the default auto-flush row count for TCP transport.
+ * @returns Default number of rows that trigger auto-flush
+ */ getDefaultAutoFlushRows() {
+ return DEFAULT_TCP_AUTO_FLUSH_ROWS;
+ }
+ /**
+ * Handles the JWK token authentication challenge-response flow.
+ * @param challenge - Challenge buffer received from the server
+ * @returns Promise resolving to true if authentication is successful
+ */ async authenticate(challenge) {
+ // Check for trailing \n which ends the challenge
+ if (challenge.subarray(-1).readInt8() === 10) {
+ const keyObject = crypto__default.default.createPrivateKey({
+ key: this.jwk,
+ format: "jwk"
+ });
+ const signature = crypto__default.default.sign("RSA-SHA256", challenge.subarray(0, challenge.length - 1), keyObject);
+ return new Promise((resolve, reject)=>{
+ this.socket.write(`${node_buffer.Buffer.from(signature).toString("base64")}\n`, (err)=>{
+ if (err) {
+ reject(err);
+ } else {
+ resolve(true);
+ }
+ });
+ });
+ }
+ return false;
+ }
+}
+// Constructs authentication configuration from username/token options.
+// @param options - Sender options that may contain authentication details
+// @throws Error if username or token is missing when authentication is intended
+function constructAuth(options) {
+ if (!options.username && !options.token && !options.password) {
+ // no intention to authenticate
+ return;
+ }
+ if (!options.username || !options.token) {
+ throw new Error("TCP transport requires a username and a private key for authentication, " + "please, specify the 'username' and 'token' config options");
+ }
+ options.auth = {
+ keyId: options.username,
+ token: options.token
+ };
+}
+// Constructs a JWK (JSON Web Key) object for cryptographic authentication.
+// @param options - Sender options containing authentication configuration
+// @returns JWK object with key ID, private key, and public key coordinates
+// @throws Error if required authentication properties are missing or invalid
+function constructJwk(options) {
+ if (options.auth) {
+ if (!options.auth.keyId) {
+ throw new Error("Missing username, please, specify the 'keyId' property of the 'auth' config option. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
+ }
+ if (typeof options.auth.keyId !== "string") {
+ throw new Error("Please, specify the 'keyId' property of the 'auth' config option as a string. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
+ }
+ if (!options.auth.token) {
+ throw new Error("Missing private key, please, specify the 'token' property of the 'auth' config option. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
+ }
+ if (typeof options.auth.token !== "string") {
+ throw new Error("Please, specify the 'token' property of the 'auth' config option as a string. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
+ }
+ return {
+ kid: options.auth.keyId,
+ d: options.auth.token,
+ ...PUBLIC_KEY,
+ kty: "EC",
+ crv: "P-256"
+ };
+ } else {
+ return options.jwk;
+ }
+}
+
+// @ts-check
+/**
+ * Default configuration for HTTP agents.
+ * - Persistent connections with 1 minute idle timeout
+ * - Maximum of 256 open connections (matching server default)
+ */ const DEFAULT_HTTP_AGENT_CONFIG = {
+ maxSockets: 256,
+ keepAlive: true,
+ timeout: 60000
+};
+/**
+ * Legacy HTTP transport implementation using Node.js built-in http/https modules. <br>
+ * Provides HTTP requests with manual retry logic and connection pooling. <br>
+ * Supports both HTTP and HTTPS protocols with configurable authentication.
+ */ class HttpTransport extends HttpTransportBase {
+ /**
+ * Creates a new HttpTransport instance using legacy Node.js HTTP modules.
+ *
+ * @param options - Sender configuration object containing connection details
+ * @throws Error if the protocol is not 'http' or 'https'
+ */ constructor(options){
+ super(options);
+ switch(options.protocol){
+ case HTTP:
+ this.agent = options.agent instanceof http__default.default.Agent ? options.agent : HttpTransport.getDefaultHttpAgent();
+ break;
+ case HTTPS:
+ this.agent = options.agent instanceof https__default.default.Agent ? options.agent : HttpTransport.getDefaultHttpsAgent();
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
+ }
+ }
+ /**
+ * Sends data to QuestDB using HTTP POST with manual retry logic.
+ * @param data - Buffer containing line protocol data to send
+ * @param retryBegin - Internal parameter for tracking retry start time
+ * @param retryInterval - Internal parameter for tracking retry intervals
+ * @returns Promise resolving to true if data was sent successfully
+ * @throws Error if request fails after all retries or times out
+ */ send(data, retryBegin = -1, retryInterval = -1) {
+ const request = this.secure ? https__default.default.request : http__default.default.request;
+ const timeoutMillis = data.length / this.requestMinThroughput * 1000 + this.requestTimeout;
+ const options = this.createRequestOptions(timeoutMillis);
+ return new Promise((resolve, reject)=>{
+ let statusCode = -1;
+ const req = request(options, (response)=>{
+ statusCode = response.statusCode;
+ const body = [];
+ response.on("data", (chunk)=>{
+ body.push(chunk);
+ }).on("error", (err)=>{
+ this.log("error", `resp err=${err}`);
+ });
+ if (statusCode === HTTP_NO_CONTENT) {
+ response.on("end", ()=>{
+ if (body.length > 0) {
+ this.log("warn", `Unexpected message from server: ${node_buffer.Buffer.concat(body)}`);
+ }
+ resolve(true);
+ });
+ } else {
+ req.destroy(new Error(`HTTP request failed, statusCode=${statusCode}, error=${node_buffer.Buffer.concat(body)}`));
+ }
+ });
+ if (this.token) {
+ req.setHeader("Authorization", `Bearer ${this.token}`);
+ } else if (this.username && this.password) {
+ req.setHeader("Authorization", `Basic ${node_buffer.Buffer.from(`${this.username}:${this.password}`).toString("base64")}`);
+ }
+ req.on("timeout", ()=>{
+ // set a retryable error code
+ statusCode = 524;
+ req.destroy(new Error("HTTP request timeout, no response from server in time"));
+ });
+ req.on("error", (err)=>{
+ // if the error is thrown while the request is sent, statusCode is -1 => no retry
+ // request timeout comes through with statusCode 524 => retry
+ // if the error is thrown while the response is processed, the statusCode is taken from the response => retry depends on statusCode
+ if (isRetryable(statusCode) && this.retryTimeout > 0) {
+ if (retryBegin < 0) {
+ retryBegin = Date.now();
+ retryInterval = 10;
+ } else {
+ const elapsed = Date.now() - retryBegin;
+ if (elapsed > this.retryTimeout) {
+ reject(err);
+ return;
+ }
+ }
+ const jitter = Math.floor(Math.random() * 10) - 5;
+ setTimeout(()=>{
+ retryInterval = Math.min(retryInterval * 2, 1000);
+ this.send(data, retryBegin, retryInterval).then(()=>resolve(true)).catch((e)=>reject(e));
+ }, retryInterval + jitter);
+ } else {
reject(err);
}
});
+ req.write(data, (err)=>err ? reject(err) : ()=>{});
+ req.end();
});
}
/**
- * @ignore
- * @return {Agent} Returns the default http agent.
- */ getDefaultHttpAgent() {
- if (!Sender.DEFAULT_HTTP_AGENT) {
- Sender.DEFAULT_HTTP_AGENT = new undici.Agent(DEFAULT_HTTP_OPTIONS);
- }
- return Sender.DEFAULT_HTTP_AGENT;
+ * Creates HTTP request options based on configuration.
+ * @param timeoutMillis - Request timeout in milliseconds
+ * @returns HTTP or HTTPS request options object
+ */ createRequestOptions(timeoutMillis) {
+ return {
+ //protocol: this.secure ? "https:" : "http:",
+ hostname: this.host,
+ port: this.port,
+ agent: this.agent,
+ path: "/write?precision=n",
+ method: "POST",
+ timeout: timeoutMillis,
+ rejectUnauthorized: this.secure && this.tlsVerify,
+ ca: this.secure ? this.tlsCA : undefined
+ };
+ }
+ /**
+ * Gets or creates the default HTTP agent with standard configuration.
+ * Uses a singleton pattern to reuse the same agent across instances.
+ * @returns The default HTTP agent instance
+ */ static getDefaultHttpAgent() {
+ if (!HttpTransport.DEFAULT_HTTP_AGENT) {
+ HttpTransport.DEFAULT_HTTP_AGENT = new http__default.default.Agent(DEFAULT_HTTP_AGENT_CONFIG);
+ }
+ return HttpTransport.DEFAULT_HTTP_AGENT;
+ }
+ /**
+ * Gets or creates the default HTTPS agent with standard configuration.
+ * Uses a singleton pattern to reuse the same agent across instances.
+ * @returns The default HTTPS agent instance
+ */ static getDefaultHttpsAgent() {
+ if (!HttpTransport.DEFAULT_HTTPS_AGENT) {
+ HttpTransport.DEFAULT_HTTPS_AGENT = new https__default.default.Agent(DEFAULT_HTTP_AGENT_CONFIG);
+ }
+ return HttpTransport.DEFAULT_HTTPS_AGENT;
+ }
+}
+/**
+ * Determines if an HTTP status code should trigger a retry.
+ * @param statusCode - HTTP status code to check
+ * @returns True if the status code indicates a retryable error
+ */ function isRetryable(statusCode) {
+ return RETRIABLE_STATUS_CODES.includes(statusCode);
+}
+
+// @ts-check
+/**
+ * Factory function to create appropriate transport instance based on configuration.
+ * @param options - Sender configuration options including protocol and connection details
+ * @returns Transport instance appropriate for the specified protocol
+ * @throws Error if protocol or host options are missing or invalid
+ */ function createTransport(options) {
+ if (!options || !options.protocol) {
+ throw new Error("The 'protocol' option is mandatory");
+ }
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
+ switch(options.protocol){
+ case HTTP:
+ case HTTPS:
+ return options.legacy_http ? new HttpTransport(options) : new UndiciTransport(options);
+ case TCP:
+ case TCPS:
+ return new TcpTransport(options);
+ default:
+ throw new Error(`Invalid protocol: '${options.protocol}'`);
+ }
+}
+
+/**
+ * Validates a table name. <br>
+ * Throws an error if table name is invalid.
+ *
+ * @param {string} name - The table name to validate.
+ * @param {number} maxNameLength - The maximum length of table names.
+ */ function validateTableName(name, maxNameLength) {
+ const len = name.length;
+ if (len > maxNameLength) {
+ throw new Error(`Table name is too long, max length is ${maxNameLength}`);
+ }
+ if (len === 0) {
+ throw new Error("Empty string is not allowed as table name");
+ }
+ for(let i = 0; i < len; i++){
+ const ch = name[i];
+ switch(ch){
+ case ".":
+ if (i === 0 || i === len - 1 || name[i - 1] === ".") // single dot is allowed in the middle only
+ // starting with a dot hides directory in Linux
+ // ending with a dot can be trimmed by some Windows versions / file systems
+ // double or triple dot looks suspicious
+ // single dot allowed as compatibility,
+ // when someone uploads 'file_name.csv' the file name used as the table name
+ throw new Error("Table name cannot start or end with a dot, and only a single dot allowed");
+ break;
+ case "?":
+ case ",":
+ case "'":
+ case '"':
+ case "\\":
+ case "/":
+ case ":":
+ case ")":
+ case "(":
+ case "+":
+ case "*":
+ case "%":
+ case "~":
+ case "\u0000":
+ case "\u0001":
+ case "\u0002":
+ case "\u0003":
+ case "\u0004":
+ case "\u0005":
+ case "\u0006":
+ case "\u0007":
+ case "\u0008":
+ case "\u0009":
+ case "\u000B":
+ case "\u000c":
+ case "\r":
+ case "\n":
+ case "\u000e":
+ case "\u000f":
+ case "\u007f":
+ case "\ufeff":
+ throw new Error(`Invalid character in table name: ${ch}`);
+ }
+ }
+}
+/**
+ * Validates a column name. <br>
+ * Throws an error if column name is invalid.
+ *
+ * @param {string} name - The column name to validate.
+ * @param {number} maxNameLength - The maximum length of column names.
+ */ function validateColumnName(name, maxNameLength) {
+ const len = name.length;
+ if (len > maxNameLength) {
+ throw new Error(`Column name is too long, max length is ${maxNameLength}`);
+ }
+ if (len === 0) {
+ throw new Error("Empty string is not allowed as column name");
+ }
+ for (const ch of name){
+ switch(ch){
+ case "?":
+ case ".":
+ case ",":
+ case "'":
+ case '"':
+ case "\\":
+ case "/":
+ case ":":
+ case ")":
+ case "(":
+ case "+":
+ case "-":
+ case "*":
+ case "%":
+ case "~":
+ case "\u0000":
+ case "\u0001":
+ case "\u0002":
+ case "\u0003":
+ case "\u0004":
+ case "\u0005":
+ case "\u0006":
+ case "\u0007":
+ case "\u0008":
+ case "\u0009":
+ case "\u000B":
+ case "\u000c":
+ case "\r":
+ case "\n":
+ case "\u000e":
+ case "\u000f":
+ case "\u007f":
+ case "\ufeff":
+ throw new Error(`Invalid character in column name: ${ch}`);
+ }
+ }
+}
+
+// @ts-check
+// Default maximum length for table and column names.
+const DEFAULT_MAX_NAME_LENGTH = 127;
+/**
+ * Abstract base class for QuestDB line protocol buffer implementations.
+ * Provides common functionality for building line protocol messages including
+ * table names, symbols, columns, and timestamps.
+ */ class SenderBufferBase {
+ /**
+ * Creates an instance of SenderBufferBase.
+ *
+ * @param options - Sender configuration object containing buffer and naming options
+ */ constructor(options){
+ this.log = options && typeof options.log === "function" ? options.log : log;
+ SenderOptions.resolveDeprecated(options, this.log);
+ this.maxNameLength = options && isInteger(options.max_name_len, 1) ? options.max_name_len : DEFAULT_MAX_NAME_LENGTH;
+ this.maxBufferSize = options && isInteger(options.max_buf_size, 1) ? options.max_buf_size : DEFAULT_MAX_BUFFER_SIZE;
+ this.resize(options && isInteger(options.init_buf_size, 1) ? options.init_buf_size : DEFAULT_BUFFER_SIZE);
+ this.reset();
+ }
+ /**
+ * Extends the size of the sender's buffer.
+ * Can be used to increase the size of buffer if overflown.
+ * The buffer's content is copied into the new buffer.
+ *
+ * @param bufferSize - New size of the buffer used by the sender, provided in bytes
+ * @throws Error if the requested buffer size exceeds the maximum allowed size
+ */ resize(bufferSize) {
+ if (bufferSize > this.maxBufferSize) {
+ throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
+ }
+ this.bufferSize = bufferSize;
+ // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
+ // longer than the size of the buffer. It simply just writes whatever it can, and returns.
+ // If we can write into the extra byte, that indicates buffer overflow.
+ // See the check in the write() function.
+ const newBuffer = node_buffer.Buffer.alloc(this.bufferSize + 1, 0);
+ if (this.buffer) {
+ this.buffer.copy(newBuffer);
+ }
+ this.buffer = newBuffer;
+ }
+ /**
+ * Resets the buffer, data added to the buffer will be lost. <br>
+ * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ *
+ * @return {Sender} Returns with a reference to this sender.
+ */ reset() {
+ this.position = 0;
+ this.startNewRow();
+ return this;
+ }
+ startNewRow() {
+ this.endOfLastRow = this.position;
+ this.hasTable = false;
+ this.hasSymbols = false;
+ this.hasColumns = false;
+ }
+ /**
+ * @ignore
+ * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
+ * The returned buffer is backed by the sender's buffer.
+ * Used only in tests.
+ */ toBufferView(pos = this.endOfLastRow) {
+ return pos > 0 ? this.buffer.subarray(0, pos) : null;
+ }
+ /**
+ * @ignore
+ * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
+ * The returned buffer is a copy of the sender's buffer.
+ * It also compacts the Sender's buffer.
+ */ toBufferNew(pos = this.endOfLastRow) {
+ if (pos > 0) {
+ const data = node_buffer.Buffer.allocUnsafe(pos);
+ this.buffer.copy(data, 0, 0, pos);
+ this.compact();
+ return data;
+ }
+ return null;
+ }
+ /**
+ * Write the table name into the buffer of the sender.
+ *
+ * @param {string} table - Table name.
+ * @return {Sender} Returns with a reference to this sender.
+ */ table(table) {
+ if (typeof table !== "string") {
+ throw new Error(`Table name must be a string, received ${typeof table}`);
+ }
+ if (this.hasTable) {
+ throw new Error("Table name has already been set");
+ }
+ validateTableName(table, this.maxNameLength);
+ this.checkCapacity([
+ table
+ ], table.length);
+ this.writeEscaped(table);
+ this.hasTable = true;
+ return this;
+ }
+ /**
+ * Write a symbol name and value into the buffer of the sender.
+ *
+ * @param {string} name - Symbol name.
+ * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
+ * @return {Sender} Returns with a reference to this sender.
+ */ symbol(name, value) {
+ if (typeof name !== "string") {
+ throw new Error(`Symbol name must be a string, received ${typeof name}`);
+ }
+ if (!this.hasTable || this.hasColumns) {
+ throw new Error("Symbol can be added only after table name is set and before any column added");
+ }
+ const valueStr = value.toString();
+ this.checkCapacity([
+ name,
+ valueStr
+ ], 2 + name.length + valueStr.length);
+ this.write(",");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ this.writeEscaped(valueStr);
+ this.hasSymbols = true;
+ return this;
+ }
+ /**
+ * Write a string column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {string} value - Column value, accepts only string values.
+ * @return {Sender} Returns with a reference to this sender.
+ */ stringColumn(name, value) {
+ this.writeColumn(name, value, ()=>{
+ this.checkCapacity([
+ value
+ ], 2 + value.length);
+ this.write('"');
+ this.writeEscaped(value, true);
+ this.write('"');
+ }, "string");
+ return this;
+ }
+ /**
+ * Write a boolean column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {boolean} value - Column value, accepts only boolean values.
+ * @return {Sender} Returns with a reference to this sender.
+ */ booleanColumn(name, value) {
+ this.writeColumn(name, value, ()=>{
+ this.checkCapacity([], 1);
+ this.write(value ? "t" : "f");
+ }, "boolean");
+ return this;
+ }
+ /**
+ * Write an integer column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */ intColumn(name, value) {
+ if (!Number.isInteger(value)) {
+ throw new Error(`Value must be an integer, received ${value}`);
+ }
+ this.writeColumn(name, value, ()=>{
+ const valueStr = value.toString();
+ this.checkCapacity([
+ valueStr
+ ], 1);
+ this.write(valueStr);
+ this.write("i");
+ });
+ return this;
+ }
+ /**
+ * Write a timestamp column with its value into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
+ * @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
+ * @return {Sender} Returns with a reference to this sender.
+ */ timestampColumn(name, value, unit = "us") {
+ if (typeof value !== "bigint" && !Number.isInteger(value)) {
+ throw new Error(`Value must be an integer or BigInt, received ${value}`);
+ }
+ this.writeColumn(name, value, ()=>{
+ const valueMicros = timestampToMicros(BigInt(value), unit);
+ const valueStr = valueMicros.toString();
+ this.checkCapacity([
+ valueStr
+ ], 1);
+ this.write(valueStr);
+ this.write("t");
+ });
+ return this;
+ }
+ /**
+ * Closing the row after writing the designated timestamp into the buffer of the sender.
+ *
+ * @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
+ * @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
+ */ at(timestamp, unit = "us") {
+ if (!this.hasSymbols && !this.hasColumns) {
+ throw new Error("The row must have a symbol or column set before it is closed");
+ }
+ if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
+ throw new Error(`Designated timestamp must be an integer or BigInt, received ${timestamp}`);
+ }
+ const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
+ const timestampStr = timestampNanos.toString();
+ this.checkCapacity([
+ timestampStr
+ ], 2);
+ this.write(" ");
+ this.write(timestampStr);
+ this.write("\n");
+ this.startNewRow();
+ }
+ /**
+ * Closing the row without writing designated timestamp into the buffer of the sender. <br>
+ * Designated timestamp will be populated by the server on this record.
+ */ atNow() {
+ if (!this.hasSymbols && !this.hasColumns) {
+ throw new Error("The row must have a symbol or column set before it is closed");
+ }
+ this.checkCapacity([], 1);
+ this.write("\n");
+ this.startNewRow();
+ }
+ /**
+ * Checks if the buffer has sufficient capacity for additional data and resizes if needed.
+ * @param data - Array of strings to calculate the required capacity for
+ * @param base - Base number of bytes to add to the calculation
+ */ checkCapacity(data, base = 0) {
+ let length = base;
+ for (const str of data){
+ length += node_buffer.Buffer.byteLength(str, "utf8");
+ }
+ if (this.position + length > this.bufferSize) {
+ let newSize = this.bufferSize;
+ do {
+ newSize += this.bufferSize;
+ }while (this.position + length > newSize)
+ this.resize(newSize);
+ }
+ }
+ /**
+ * Compacts the buffer by removing data from completed rows.
+ * Moves any remaining data to the beginning of the buffer.
+ */ compact() {
+ if (this.endOfLastRow > 0) {
+ this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
+ this.position = this.position - this.endOfLastRow;
+ this.endOfLastRow = 0;
+ }
+ }
+ /**
+ * Common logic for writing column data to the buffer.
+ * @param name - Column name
+ * @param value - Column value
+ * @param writeValue - Function to write the value portion to the buffer
+ * @param valueType - Optional expected type for validation
+ */ writeColumn(name, value, writeValue, valueType) {
+ if (typeof name !== "string") {
+ throw new Error(`Column name must be a string, received ${typeof name}`);
+ }
+ if (valueType && typeof value !== valueType) {
+ throw new Error(`Column value must be of type ${valueType}, received ${typeof value}`);
+ }
+ if (!this.hasTable) {
+ throw new Error("Column can be set only after table name is set");
+ }
+ this.checkCapacity([
+ name
+ ], 2 + name.length);
+ this.write(this.hasColumns ? "," : " ");
+ validateColumnName(name, this.maxNameLength);
+ this.writeEscaped(name);
+ this.write("=");
+ writeValue();
+ this.assertBufferOverflow();
+ this.hasColumns = true;
+ }
+ /**
+ * Writes string data to the buffer at the current position.
+ * @param data - String data to write
+ */ write(data) {
+ this.position += this.buffer.write(data, this.position);
+ }
+ /**
+ * Writes a single byte to the buffer at the current position.
+ * @param data - Byte value to write
+ */ writeByte(data) {
+ this.position = this.buffer.writeInt8(data, this.position);
+ }
+ /**
+ * Writes a 32-bit integer to the buffer in little-endian format.
+ * @param data - Integer value to write
+ */ writeInt(data) {
+ this.position = this.buffer.writeInt32LE(data, this.position);
+ }
+ /**
+ * Writes a double-precision float to the buffer in little-endian format.
+ * @param data - Double value to write
+ */ writeDouble(data) {
+ this.position = this.buffer.writeDoubleLE(data, this.position);
+ }
+ /**
+ * Writes array data to the buffer including dimensions and values.
+ * @param arr - Array to write to the buffer
+ */ writeArray(arr, dimensions, type) {
+ this.checkCapacity([], 1 + dimensions.length * 4);
+ this.writeByte(dimensions.length);
+ for(let i = 0; i < dimensions.length; i++){
+ this.writeInt(dimensions[i]);
+ }
+ this.checkCapacity([], SenderBufferBase.arraySize(dimensions, type));
+ this.writeArrayValues(arr, dimensions);
+ }
+ writeArrayValues(arr, dimensions) {
+ if (Array.isArray(arr[0])) {
+ for(let i = 0; i < arr.length; i++){
+ this.writeArrayValues(arr[i], dimensions);
+ }
+ } else {
+ const type = typeof arr[0];
+ switch(type){
+ case "number":
+ for(let i = 0; i < arr.length; i++){
+ this.position = this.buffer.writeDoubleLE(arr[i], this.position);
+ }
+ break;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
+ }
+ writeEscaped(data, quoted = false) {
+ for (const ch of data){
+ if (ch > "\\") {
+ this.write(ch);
+ continue;
+ }
+ switch(ch){
+ case " ":
+ case ",":
+ case "=":
+ if (!quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\n":
+ case "\r":
+ this.write("\\");
+ this.write(ch);
+ break;
+ case '"':
+ if (quoted) {
+ this.write("\\");
+ }
+ this.write(ch);
+ break;
+ case "\\":
+ this.write("\\\\");
+ break;
+ default:
+ this.write(ch);
+ break;
+ }
+ }
+ }
+ static arraySize(dimensions, type) {
+ let numOfElements = 1;
+ for(let i = 0; i < dimensions.length; i++){
+ numOfElements *= dimensions[i];
+ }
+ switch(type){
+ case "number":
+ return numOfElements * 8;
+ case "boolean":
+ return numOfElements;
+ case "string":
+ // in case of string[] capacity check is done separately for each array element
+ return 0;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
+ assertBufferOverflow() {
+ if (this.position > this.bufferSize) {
+ // should never happen, if checkCapacity() is correctly used
+ throw new Error(`Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`);
+ }
+ }
+}
+
+// @ts-check
+/**
+ * Buffer implementation for QuestDB line protocol version 1.
+ * Supports basic column types but does not support array columns.
+ */ class SenderBufferV1 extends SenderBufferBase {
+ /**
+ * Creates a new SenderBufferV1 instance.
+ * @param options - Sender configuration options
+ */ constructor(options){
+ super(options);
+ }
+ /**
+ * Write a float column with its value into the buffer using v1 format.
+ * @param name - Column name
+ * @param value - Float value to write
+ * @returns Reference to this sender buffer for method chaining
+ */ floatColumn(name, value) {
+ this.writeColumn(name, value, ()=>{
+ const valueStr = value.toString();
+ this.checkCapacity([
+ valueStr
+ ]);
+ this.write(valueStr);
+ }, "number");
+ return this;
+ }
+ /**
+ * Array columns are not supported in protocol v1.
+ * @throws Error indicating arrays are not supported in v1
+ */ arrayColumn() {
+ throw new Error("Arrays are not supported in protocol v1");
+ }
+}
+
+// @ts-check
+// Column type constants for protocol v2.
+const COLUMN_TYPE_DOUBLE = 10;
+const COLUMN_TYPE_NULL = 33;
+// Entity type constants for protocol v2.
+const ENTITY_TYPE_ARRAY = 14;
+const ENTITY_TYPE_DOUBLE = 16;
+// ASCII code for equals sign used in binary protocol.
+const EQUALS_SIGN = "=".charCodeAt(0);
+/**
+ * Buffer implementation for QuestDB line protocol version 2.
+ * Supports all column types including arrays with binary encoding for doubles.
+ */ class SenderBufferV2 extends SenderBufferBase {
+ /**
+ * Creates a new SenderBufferV2 instance.
+ * @param options - Sender configuration options
+ */ constructor(options){
+ super(options);
+ }
+ /**
+ * Write a float column with its value into the buffer using v2 binary format.
+ * @param name - Column name
+ * @param value - Float value to write
+ * @returns Reference to this sender buffer for method chaining
+ */ floatColumn(name, value) {
+ this.writeColumn(name, value, ()=>{
+ this.checkCapacity([], 10);
+ this.writeByte(EQUALS_SIGN);
+ this.writeByte(ENTITY_TYPE_DOUBLE);
+ this.writeDouble(value);
+ }, "number");
+ return this;
+ }
+ /**
+ * Write an array column with its values into the buffer using v2 format.
+ * @param name - Column name
+ * @param value - Array values to write (currently supports double arrays)
+ * @returns Reference to this sender buffer for method chaining
+ * @throws Error if value is not an array when provided
+ */ arrayColumn(name, value) {
+ const dimensions = getDimensions(value);
+ const type = validateArray(value, dimensions);
+ // only number arrays and NULL supported for now
+ if (type !== "number" && type !== null) {
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ this.writeColumn(name, value, ()=>{
+ this.checkCapacity([], 3);
+ this.writeByte(EQUALS_SIGN);
+ this.writeByte(ENTITY_TYPE_ARRAY);
+ if (!value) {
+ this.writeByte(COLUMN_TYPE_NULL);
+ } else {
+ this.writeByte(COLUMN_TYPE_DOUBLE);
+ this.writeArray(value, dimensions, type);
+ }
+ });
+ return this;
+ }
+}
+
+// @ts-check
+// Default initial buffer size in bytes (64 KB).
+const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
+// Default maximum buffer size in bytes (100 MB).
+const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
+/**
+ * Factory function to create a SenderBuffer instance based on the protocol version.
+ * @param options - Sender configuration options
+ * @returns A SenderBuffer instance appropriate for the specified protocol version
+ * @throws Error if protocol version is not specified or is unsupported
+ */ function createBuffer(options) {
+ switch(options.protocol_version){
+ case PROTOCOL_VERSION_V2:
+ return new SenderBufferV2(options);
+ case PROTOCOL_VERSION_V1:
+ return new SenderBufferV1(options);
+ case PROTOCOL_VERSION_AUTO:
+ case undefined:
+ case null:
+ case "":
+ throw new Error("Provide the 'protocol_version' option, or call 'await SenderOptions.resolveAuto(options)' first");
+ default:
+ throw new Error("Unsupported protocol version: " + options.protocol_version);
+ }
+}
+
+// @ts-check
+const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
+/** @classdesc
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection. <br>
+ * The client supports multiple transport protocols.
+ * <p>
+ * <b>Transport Options:</b>
+ * <ul>
+ * <li><b>HTTP (http://)</b>: Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes.
+ * Recommended for most use cases due to superior error handling and debugging capabilities. Uses Undici library by default for high performance.</li>
+ * <li><b>HTTPS (https://)</b>: Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication.
+ * Supports certificate validation and custom CA certificates.</li>
+ * <li><b>TCP (tcp://)</b>: Direct TCP connection, provides persistent connections. Uses JWK token-based authentication.</li>
+ * <li><b>TCPS (tcps://)</b>: Secure TCP transport with TLS encryption.</li>
+ * </ul>
+ * </p>
+ * <p>
+ * The client supports authentication. <br>
+ * Authentication details can be passed to the Sender in its configuration options. <br>
+ * The client supports Basic username/password and Bearer token authentication methods when used with HTTP protocol,
+ * and JWK token authentication when ingesting data via TCP. <br>
+ * Please, note that authentication is enabled by default in QuestDB Enterprise only. <br>
+ * Details on how to configure authentication in the open source version of
+ * QuestDB: {@link https://questdb.io/docs/reference/api/ilp/authenticate}
+ * </p>
+ * <p>
+ * The client also supports TLS encryption for both, HTTP and TCP transports to provide a secure connection. <br>
+ * Please, note that the open source version of QuestDB does not support TLS, and requires an external reverse-proxy,
+ * such as Nginx to enable encryption.
+ * </p>
+ * <p>
+ * The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
+ * serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
+ * performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS
+ * by setting the protocol_version to 'auto' (default behavior).
+ * </p>
+ * <p>
+ * The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server.
+ * Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum
+ * buffer sizes can also be set.
+ * </p>
+ * <p>
+ * It is recommended that the Sender is created by using one of the static factory methods,
+ * <i>Sender.fromConfig(configString, extraOptions)</i> or <i>Sender.fromEnv(extraOptions)</i>.
+ * If the Sender is created via its constructor, at least the SenderOptions configuration object should be
+ * initialized from a configuration string to make sure that the parameters are validated. <br>
+ * Detailed description of the Sender's configuration options can be found in
+ * the <a href="SenderOptions.html">SenderOptions</a> documentation.
+ * </p>
+ * <p>
+ * <b>Transport Configuration Examples:</b>
+ * <ul>
+ * <li>HTTP: <i>Sender.fromConfig("http::addr=localhost:9000")</i></li>
+ * <li>HTTPS with authentication: <i>Sender.fromConfig("https::addr=localhost:9000;username=admin;password=secret")</i></li>
+ * <li>TCP with authentication: <i>Sender.fromConfig("tcp::addr=localhost:9009;username=user;token=private_key")</i></li>
+ * <li>Legacy HTTP transport: <i>Sender.fromConfig("http::addr=localhost:9000;legacy_http=on")</i></li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>HTTP Transport Implementation:</b><br>
+ * By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling.
+ * For compatibility or specific requirements, you can enable the legacy HTTP transport using Node.js built-in modules
+ * by setting <i>legacy_http=on</i> in the configuration string. The legacy transport provides the same functionality
+ * but uses Node.js http/https modules instead of Undici.
+ * </p>
+ * <p>
+ * Extra options can be provided to the Sender in the <i>extraOptions</i> configuration object. <br>
+ * A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object. <br>
+ * The logger implementation provides the option to direct log messages to the same place where the host application's
+ * log is saved. The default logger writes to the console. <br>
+ * The custom HTTP(S) agent option becomes handy if there is a need to modify the default options set for the
+ * HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be
+ * passed to the Sender with <i>keepAlive</i> set to <i>false</i>. <br>
+ * For example: <i>Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})</i> <br>
+ * If no custom agent is configured, the Sender will use its own agent which overrides some default values
+ * of <i>undici.Agent</i>. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
+ * </p>
+ */ class Sender {
+ /**
+ * Creates an instance of Sender.
+ *
+ * @param {SenderOptions} options - Sender configuration object. <br>
+ * See SenderOptions documentation for detailed description of configuration options. <br>
+ */ constructor(options){
+ this.transport = createTransport(options);
+ this.buffer = createBuffer(options);
+ this.log = typeof options.log === "function" ? options.log : log;
+ this.autoFlush = isBoolean(options.auto_flush) ? options.auto_flush : true;
+ this.autoFlushRows = isInteger(options.auto_flush_rows, 0) ? options.auto_flush_rows : this.transport.getDefaultAutoFlushRows();
+ this.autoFlushInterval = isInteger(options.auto_flush_interval, 0) ? options.auto_flush_interval : DEFAULT_AUTO_FLUSH_INTERVAL;
+ this.reset();
+ }
+ /**
+ * Creates a Sender options object by parsing the provided configuration string.
+ *
+ * @param {string} configurationString - Configuration string. <br>
+ * @param {object} extraOptions - Optional extra configuration. <br>
+ * - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
+ * Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
+ * - 'agent' is a custom Undici agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
+ * A <i>undici.Agent</i> object is expected.
+ *
+ * @return {Sender} A Sender object initialized from the provided configuration string.
+ */ static async fromConfig(configurationString, extraOptions) {
+ return new Sender(await SenderOptions.fromConfig(configurationString, extraOptions));
}
/**
- * Closes the TCP connection to the database. <br>
- * Data sitting in the Sender's buffer will be lost unless flush() is called before close().
- */ async close() {
- if (this.socket) {
- const address = this.socket.remoteAddress;
- const port = this.socket.remotePort;
- this.socket.destroy();
- this.socket = null;
- this.log("info", `Connection to ${address}:${port} is closed`);
- }
+ * Creates a Sender options object by parsing the configuration string set in the <b>QDB_CLIENT_CONF</b> environment variable.
+ *
+ * @param {object} extraOptions - Optional extra configuration. <br>
+ * - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
+ * Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
+ * - 'agent' is a custom Undici agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
+ * A <i>undici.Agent</i> object is expected.
+ *
+ * @return {Sender} A Sender object initialized from the <b>QDB_CLIENT_CONF</b> environment variable.
+ */ static async fromEnv(extraOptions) {
+ return new Sender(await SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions));
+ }
+ /**
+ * Resets the buffer, data added to the buffer will be lost. <br>
+ * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ *
+ * @return {Sender} Returns with a reference to this sender.
+ */ reset() {
+ this.buffer.reset();
+ this.resetAutoFlush();
+ return this;
+ }
+ /**
+ * Creates a TCP connection to the database.
+ *
+ * @return {Promise<boolean>} Resolves to true if the client is connected.
+ */ connect() {
+ return this.transport.connect();
}
/**
* Sends the buffer's content to the database and compacts the buffer.
* If the last row is not finished it stays in the sender's buffer.
*
- * @return {Promise<boolean>} Resolves to true when there was data in the buffer to send.
+ * @return {Promise<boolean>} Resolves to true when there was data in the buffer to send, and it was sent successfully.
*/ async flush() {
- const data = this.toBuffer(this.endOfLastRow);
- if (!data) {
- return false;
- }
- if (this.http) {
- // const request = this.secure ? https.request : http.request;
- const options = createRequestOptions(this, data);
- return sendHttp(this, options, data, this.retryTimeout);
- } else {
- if (!this.socket) {
- throw new Error("Sender is not connected");
- }
- return sendTcp(this, data);
+ const dataToSend = this.buffer.toBufferNew();
+ if (!dataToSend) {
+ return false; // Nothing to send
}
+ this.log("debug", `Flushing, number of flushed rows: ${this.pendingRowCount}`);
+ this.resetAutoFlush();
+ await this.transport.send(dataToSend);
}
/**
- * @ignore
- * @return {Buffer} Returns a cropped buffer ready to send to the server or null if there is nothing to send.
- * The returned buffer is backed by the sender's buffer.
- */ toBufferView(pos = this.position) {
- return pos > 0 ? this.buffer.subarray(0, pos) : null;
- }
- /**
- * @ignore
- * @return {Buffer|null} Returns a cropped buffer ready to send to the server or null if there is nothing to send.
- * The returned buffer is a copy of the sender's buffer.
- */ toBufferNew(pos = this.position) {
- if (pos > 0) {
- const data = node_buffer.Buffer.allocUnsafe(pos);
- this.buffer.copy(data, 0, 0, pos);
- compact(this);
- return data;
- }
- return null;
+ * Closes the TCP connection to the database. <br>
+ * Data sitting in the Sender's buffer will be lost unless flush() is called before close().
+ */ async close() {
+ return this.transport.close();
}
/**
* Write the table name into the buffer of the sender.
@@ -897,44 +2019,17 @@
Source: index.js
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
*/ table(table) {
- if (typeof table !== "string") {
- throw new Error(`Table name must be a string, received ${typeof table}`);
- }
- if (this.hasTable) {
- throw new Error("Table name has already been set");
- }
- validateTableName(table, this.maxNameLength);
- checkCapacity(this, [
- table
- ]);
- writeEscaped(this, table);
- this.hasTable = true;
+ this.buffer.table(table);
return this;
}
/**
* Write a symbol name and value into the buffer of the sender.
*
* @param {string} name - Symbol name.
- * @param {any} value - Symbol value, toString() will be called to extract the actual symbol value from the parameter.
+ * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
* @return {Sender} Returns with a reference to this sender.
*/ symbol(name, value) {
- if (typeof name !== "string") {
- throw new Error(`Symbol name must be a string, received ${typeof name}`);
- }
- if (!this.hasTable || this.hasColumns) {
- throw new Error("Symbol can be added only after table name is set and before any column added");
- }
- const valueStr = value.toString();
- checkCapacity(this, [
- name,
- valueStr
- ], 2 + name.length + valueStr.length);
- write(this, ",");
- validateColumnName(name, this.maxNameLength);
- writeEscaped(this, name);
- write(this, "=");
- writeEscaped(this, valueStr);
- this.hasSymbols = true;
+ this.buffer.symbol(name, value);
return this;
}
/**
@@ -944,14 +2039,7 @@
Source: index.js
* @param {string} value - Column value, accepts only string values.
* @return {Sender} Returns with a reference to this sender.
*/ stringColumn(name, value) {
- writeColumn(this, name, value, ()=>{
- checkCapacity(this, [
- value
- ], 2 + value.length);
- write(this, '"');
- writeEscaped(this, value, true);
- write(this, '"');
- }, "string");
+ this.buffer.stringColumn(name, value);
return this;
}
/**
@@ -961,10 +2049,7 @@
Source: index.js
* @param {boolean} value - Column value, accepts only boolean values.
* @return {Sender} Returns with a reference to this sender.
*/ booleanColumn(name, value) {
- writeColumn(this, name, value, ()=>{
- checkCapacity(this, [], 1);
- write(this, value ? "t" : "f");
- }, "boolean");
+ this.buffer.booleanColumn(name, value);
return this;
}
/**
@@ -974,13 +2059,20 @@
Source: index.js
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
*/ floatColumn(name, value) {
- writeColumn(this, name, value, ()=>{
- const valueStr = value.toString();
- checkCapacity(this, [
- valueStr
- ], valueStr.length);
- write(this, valueStr);
- }, "number");
+ this.buffer.floatColumn(name, value);
+ return this;
+ }
+ /**
+ * Write an array column with its values into the buffer of the sender. <br>
+ * <b>Note:</b> Array columns are only supported in protocol version 2. If using protocol version 1,
+ * this method will throw an error.
+ *
+ * @param {string} name - Column name.
+ * @param {unknown[]} value - Array values to be written. Currently supports arrays of numbers.
+ * @return {Sender} Returns with a reference to this sender.
+ * @throws {Error} If protocol version 1 is used, as arrays are not supported.
+ */ arrayColumn(name, value) {
+ this.buffer.arrayColumn(name, value);
return this;
}
/**
@@ -990,17 +2082,7 @@
Source: index.js
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
*/ intColumn(name, value) {
- if (!Number.isInteger(value)) {
- throw new Error(`Value must be an integer, received ${value}`);
- }
- writeColumn(this, name, value, ()=>{
- const valueStr = value.toString();
- checkCapacity(this, [
- valueStr
- ], 1 + valueStr.length);
- write(this, valueStr);
- write(this, "i");
- });
+ this.buffer.intColumn(name, value);
return this;
}
/**
@@ -1011,18 +2093,7 @@
Source: index.js
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
* @return {Sender} Returns with a reference to this sender.
*/ timestampColumn(name, value, unit = "us") {
- if (typeof value !== "bigint" && !Number.isInteger(value)) {
- throw new Error(`Value must be an integer or BigInt, received ${value}`);
- }
- writeColumn(this, name, value, ()=>{
- const valueMicros = timestampToMicros(BigInt(value), unit);
- const valueStr = valueMicros.toString();
- checkCapacity(this, [
- valueStr
- ], 1 + valueStr.length);
- write(this, valueStr);
- write(this, "t");
- });
+ this.buffer.timestampColumn(name, value, unit);
return this;
}
/**
@@ -1031,333 +2102,29 @@
Source: index.js
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
*/ async at(timestamp, unit = "us") {
- if (!this.hasSymbols && !this.hasColumns) {
- throw new Error("The row must have a symbol or column set before it is closed");
- }
- if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) {
- throw new Error(`Designated timestamp must be an integer or BigInt, received ${timestamp}`);
- }
- const timestampNanos = timestampToNanos(BigInt(timestamp), unit);
- const timestampStr = timestampNanos.toString();
- checkCapacity(this, [], 2 + timestampStr.length);
- write(this, " ");
- write(this, timestampStr);
- write(this, "\n");
+ this.buffer.at(timestamp, unit);
this.pendingRowCount++;
- startNewRow(this);
- await autoFlush(this);
+ this.log("debug", `Pending row count: ${this.pendingRowCount}`);
+ await this.tryFlush();
}
/**
* Closing the row without writing designated timestamp into the buffer of the sender. <br>
* Designated timestamp will be populated by the server on this record.
*/ async atNow() {
- if (!this.hasSymbols && !this.hasColumns) {
- throw new Error("The row must have a symbol or column set before it is closed");
- }
- checkCapacity(this, [], 1);
- write(this, "\n");
+ this.buffer.atNow();
this.pendingRowCount++;
- startNewRow(this);
- await autoFlush(this);
- }
-}
-function isBoolean(value) {
- return typeof value === "boolean";
-}
-function isInteger(value, lowerBound) {
- return typeof value === "number" && Number.isInteger(value) && value >= lowerBound;
-}
-async function authenticate(sender, challenge) {
- // Check for trailing \n which ends the challenge
- if (challenge.subarray(-1).readInt8() === 10) {
- const keyObject = crypto__default.default.createPrivateKey({
- key: sender.jwk,
- format: "jwk"
- });
- const signature = crypto__default.default.sign("RSA-SHA256", challenge.subarray(0, challenge.length - 1), keyObject);
- return new Promise((resolve, reject)=>{
- sender.socket.write(`${node_buffer.Buffer.from(signature).toString("base64")}\n`, (err)=>{
- if (err) {
- reject(err);
- } else {
- resolve(true);
- }
- });
- });
- }
- return false;
-}
-function startNewRow(sender) {
- sender.endOfLastRow = sender.position;
- sender.hasTable = false;
- sender.hasSymbols = false;
- sender.hasColumns = false;
-}
-function createRequestOptions(sender, data) {
- const timeoutMillis = data.length / sender.requestMinThroughput * 1000 + sender.requestTimeout;
- const options = {
- hostname: sender.host,
- port: sender.port,
- agent: sender.agent,
- protocol: sender.secure ? "https" : "http",
- path: "/write?precision=n",
- method: "POST",
- timeout: timeoutMillis
- };
- return options;
-}
-async function sendHttp(sender, options, data, retryTimeout) {
- const retryBegin = Date.now();
- const headers = {};
- if (sender.secure) {
- sender.agent = new undici.Agent({
- ...DEFAULT_HTTP_OPTIONS,
- connect: {
- ...DEFAULT_HTTP_OPTIONS.connect,
- requestCert: sender.tlsVerify,
- rejectUnauthorized: sender.tlsVerify,
- ca: sender.tlsCA
- }
- });
- }
- const dispatcher = new undici.RetryAgent(sender.agent, {
- maxRetries: Infinity,
- minTimeout: 10,
- maxTimeout: 1000,
- timeoutFactor: 2,
- retryAfter: true,
- methods: [
- "GET",
- "POST",
- "PUT",
- "DELETE",
- "PATCH",
- "OPTIONS",
- "HEAD"
- ],
- statusCodes: RETRIABLE_STATUS_CODES,
- errorCodes: [
- "ECONNRESET",
- "EAI_AGAIN",
- "ECONNREFUSED",
- "ETIMEDOUT",
- "EPIPE",
- "UND_ERR_CONNECT_TIMEOUT",
- "UND_ERR_HEADERS_TIMEOUT",
- "UND_ERR_BODY_TIMEOUT"
- ],
- retry (err, context, callback) {
- const elapsed = Date.now() - retryBegin;
- if (elapsed > retryTimeout) {
- // Stop retrying if the total retry timeout is exceeded
- return callback(err);
- }
- return callback(null);
- }
- });
- if (sender.token) {
- headers["Authorization"] = "Bearer " + sender.token;
- } else if (sender.username && sender.password) {
- headers["Authorization"] = "Basic " + node_buffer.Buffer.from(sender.username + ":" + sender.password).toString("base64");
- }
- try {
- const { statusCode, body } = await dispatcher.request({
- origin: `${options.protocol}://${options.hostname}:${options.port}`,
- path: options.path,
- method: options.method,
- headers,
- body: data,
- headersTimeout: sender.requestTimeout
- });
- const responseBody = await body.arrayBuffer();
- if (statusCode === HTTP_NO_CONTENT) {
- if (responseBody.byteLength > 0) {
- sender.log("warn", `Unexpected message from server: ${responseBody.toString()}`);
- }
- return true;
- } else {
- const error = new Error(`HTTP request failed, statusCode=${statusCode}, error=${responseBody.toString()}`);
- throw error;
- }
- } catch (err) {
- if (err.code === "UND_ERR_HEADERS_TIMEOUT") {
- sender.log("error", `HTTP request timeout, no response from server in time`);
- throw new Error(`HTTP request timeout, no response from server in time`);
- }
- sender.log("error", `HTTP request failed, statusCode=500, error=`);
- throw new Error(`HTTP request failed, statusCode=500, error=${err.message}`);
- }
-}
-async function autoFlush(sender) {
- if (sender.autoFlush && sender.pendingRowCount > 0 && (sender.autoFlushRows > 0 && sender.pendingRowCount >= sender.autoFlushRows || sender.autoFlushInterval > 0 && Date.now() - sender.lastFlushTime >= sender.autoFlushInterval)) {
- await sender.flush();
- }
-}
-function sendTcp(sender, data) {
- return new Promise((resolve, reject)=>{
- sender.socket.write(data, (err)=>{
- if (err) {
- reject(err);
- } else {
- sender.doResolve(resolve);
- }
- });
- });
-}
-function checkCapacity(sender, data, base = 0) {
- let length = base;
- for (const str of data){
- length += node_buffer.Buffer.byteLength(str, "utf8");
- }
- if (sender.position + length > sender.bufferSize) {
- let newSize = sender.bufferSize;
- do {
- newSize += sender.bufferSize;
- }while (sender.position + length > newSize)
- sender.resize(newSize);
- }
-}
-function compact(sender) {
- if (sender.endOfLastRow > 0) {
- sender.buffer.copy(sender.buffer, 0, sender.endOfLastRow, sender.position);
- sender.position = sender.position - sender.endOfLastRow;
- sender.endOfLastRow = 0;
- sender.lastFlushTime = Date.now();
- sender.pendingRowCount = 0;
- }
-}
-function writeColumn(sender, name, value, writeValue, valueType) {
- if (typeof name !== "string") {
- throw new Error(`Column name must be a string, received ${typeof name}`);
- }
- if (valueType != null && typeof value !== valueType) {
- throw new Error(`Column value must be of type ${valueType}, received ${typeof value}`);
- }
- if (!sender.hasTable) {
- throw new Error("Column can be set only after table name is set");
- }
- checkCapacity(sender, [
- name
- ], 2 + name.length);
- write(sender, sender.hasColumns ? "," : " ");
- validateColumnName(name, sender.maxNameLength);
- writeEscaped(sender, name);
- write(sender, "=");
- writeValue();
- sender.hasColumns = true;
-}
-function write(sender, data) {
- sender.position += sender.buffer.write(data, sender.position);
- if (sender.position > sender.bufferSize) {
- throw new Error(`Buffer overflow [position=${sender.position}, bufferSize=${sender.bufferSize}]`);
- }
-}
-function writeEscaped(sender, data, quoted = false) {
- for (const ch of data){
- if (ch > "\\") {
- write(sender, ch);
- continue;
- }
- switch(ch){
- case " ":
- case ",":
- case "=":
- if (!quoted) {
- write(sender, "\\");
- }
- write(sender, ch);
- break;
- case "\n":
- case "\r":
- write(sender, "\\");
- write(sender, ch);
- break;
- case '"':
- if (quoted) {
- write(sender, "\\");
- }
- write(sender, ch);
- break;
- case "\\":
- write(sender, "\\\\");
- break;
- default:
- write(sender, ch);
- break;
- }
- }
-}
-function timestampToMicros(timestamp, unit) {
- switch(unit){
- case "ns":
- return timestamp / 1000n;
- case "us":
- return timestamp;
- case "ms":
- return timestamp * 1000n;
- default:
- throw new Error("Unknown timestamp unit: " + unit);
- }
-}
-function timestampToNanos(timestamp, unit) {
- switch(unit){
- case "ns":
- return timestamp;
- case "us":
- return timestamp * 1000n;
- case "ms":
- return timestamp * 1000_000n;
- default:
- throw new Error("Unknown timestamp unit: " + unit);
- }
-}
-function replaceDeprecatedOptions(options) {
- // deal with deprecated options
- if (options.copyBuffer) {
- options.copy_buffer = options.copyBuffer;
- options.copyBuffer = undefined;
- }
- if (options.bufferSize) {
- options.init_buf_size = options.bufferSize;
- options.bufferSize = undefined;
- }
-}
-function constructAuth(options) {
- if (!options.username && !options.token && !options.password) {
- // no intention to authenticate
- return;
+ this.log("debug", `Pending row count: ${this.pendingRowCount}`);
+ await this.tryFlush();
}
- if (!options.username || !options.token) {
- throw new Error("TCP transport requires a username and a private key for authentication, " + "please, specify the 'username' and 'token' config options");
+ resetAutoFlush() {
+ this.lastFlushTime = Date.now();
+ this.pendingRowCount = 0;
+ this.log("debug", `Pending row count: ${this.pendingRowCount}`);
}
- options.auth = {
- keyId: options.username,
- token: options.token
- };
-}
-function constructJwk(options) {
- if (options.auth) {
- if (!options.auth.keyId) {
- throw new Error("Missing username, please, specify the 'keyId' property of the 'auth' config option. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
- }
- if (typeof options.auth.keyId !== "string") {
- throw new Error("Please, specify the 'keyId' property of the 'auth' config option as a string. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
- }
- if (!options.auth.token) {
- throw new Error("Missing private key, please, specify the 'token' property of the 'auth' config option. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
+ async tryFlush() {
+ if (this.autoFlush && this.pendingRowCount > 0 && (this.autoFlushRows > 0 && this.pendingRowCount >= this.autoFlushRows || this.autoFlushInterval > 0 && Date.now() - this.lastFlushTime >= this.autoFlushInterval)) {
+ await this.flush();
}
- if (typeof options.auth.token !== "string") {
- throw new Error("Please, specify the 'token' property of the 'auth' config option as a string. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
- }
- return {
- kid: options.auth.keyId,
- d: options.auth.token,
- ...PUBLIC_KEY,
- kty: "EC",
- crv: "P-256"
- };
- } else {
- return options.jwk;
}
}
@@ -1372,13 +2139,13 @@
Source: index.js
diff --git a/docs/scripts/prettify/Apache-License-2.0.txt b/docs/scripts/prettify/Apache-License-2.0.txt
old mode 100755
new mode 100644
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index 2d0ab23..138712d 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -17,13 +17,13 @@ import {
TimestampUnit,
} from "../utils";
+// Default maximum length for table and column names.
const DEFAULT_MAX_NAME_LENGTH = 127;
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * If no custom agent is configured, the Sender will use its own agent which overrides some default values
- * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
- *
+/**
+ * Abstract base class for QuestDB line protocol buffer implementations.
+ * Provides common functionality for building line protocol messages including
+ * table names, symbols, columns, and timestamps.
*/
abstract class SenderBufferBase implements SenderBuffer {
private bufferSize: number;
@@ -41,10 +41,9 @@ abstract class SenderBufferBase implements SenderBuffer {
protected readonly log: Logger;
/**
- * Creates an instance of Sender.
+ * Creates an instance of SenderBufferBase.
*
- * @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
+ * @param options - Sender configuration object containing buffer and naming options
*/
protected constructor(options: SenderOptions) {
this.log = options && typeof options.log === "function" ? options.log : log;
@@ -69,11 +68,12 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Extends the size of the sender's buffer.
+ * Extends the size of the sender's buffer.
* Can be used to increase the size of buffer if overflown.
* The buffer's content is copied into the new buffer.
*
- * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes.
+ * @param bufferSize - New size of the buffer used by the sender, provided in bytes
+ * @throws Error if the requested buffer size exceeds the maximum allowed size
*/
private resize(bufferSize: number) {
if (bufferSize > this.maxBufferSize) {
@@ -236,6 +236,14 @@ abstract class SenderBufferBase implements SenderBuffer {
*/
abstract floatColumn(name: string, value: number): SenderBuffer;
+ /**
+ * Write an array column with its values into the buffer of the sender.
+ * Must be implemented by concrete buffer classes based on protocol version.
+ *
+ * @param name - Column name
+ * @param value - Array values to be written
+ * @returns Reference to this sender buffer for method chaining
+ */
abstract arrayColumn(name: string, value: unknown[]): SenderBuffer;
/**
@@ -325,6 +333,11 @@ abstract class SenderBufferBase implements SenderBuffer {
this.startNewRow();
}
+ /**
+ * Checks if the buffer has sufficient capacity for additional data and resizes if needed.
+ * @param data - Array of strings to calculate the required capacity for
+ * @param base - Base number of bytes to add to the calculation
+ */
protected checkCapacity(data: string[], base = 0) {
let length = base;
for (const str of data) {
@@ -339,6 +352,10 @@ abstract class SenderBufferBase implements SenderBuffer {
}
}
+ /**
+ * Compacts the buffer by removing data from completed rows.
+ * Moves any remaining data to the beginning of the buffer.
+ */
private compact() {
if (this.endOfLastRow > 0) {
this.buffer.copy(this.buffer, 0, this.endOfLastRow, this.position);
@@ -347,6 +364,13 @@ abstract class SenderBufferBase implements SenderBuffer {
}
}
+ /**
+ * Common logic for writing column data to the buffer.
+ * @param name - Column name
+ * @param value - Column value
+ * @param writeValue - Function to write the value portion to the buffer
+ * @param valueType - Optional expected type for validation
+ */
protected writeColumn(
name: string,
value: unknown,
@@ -374,22 +398,42 @@ abstract class SenderBufferBase implements SenderBuffer {
this.hasColumns = true;
}
+ /**
+ * Writes string data to the buffer at the current position.
+ * @param data - String data to write
+ */
protected write(data: string) {
this.position += this.buffer.write(data, this.position);
}
+ /**
+ * Writes a single byte to the buffer at the current position.
+ * @param data - Byte value to write
+ */
protected writeByte(data: number) {
this.position = this.buffer.writeInt8(data, this.position);
}
+ /**
+ * Writes a 32-bit integer to the buffer in little-endian format.
+ * @param data - Integer value to write
+ */
protected writeInt(data: number) {
this.position = this.buffer.writeInt32LE(data, this.position);
}
+ /**
+ * Writes a double-precision float to the buffer in little-endian format.
+ * @param data - Double value to write
+ */
protected writeDouble(data: number) {
this.position = this.buffer.writeDoubleLE(data, this.position);
}
+ /**
+ * Writes array data to the buffer including dimensions and values.
+ * @param arr - Array to write to the buffer
+ */
protected writeArray(
arr: unknown[],
dimensions: number[],
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
index 0efe7c5..8598dbe 100644
--- a/src/buffer/bufferv1.ts
+++ b/src/buffer/bufferv1.ts
@@ -3,11 +3,25 @@ import { SenderOptions } from "../options";
import { SenderBuffer } from "./index";
import { SenderBufferBase } from "./base";
+/**
+ * Buffer implementation for QuestDB line protocol version 1.
+ * Supports basic column types but does not support array columns.
+ */
class SenderBufferV1 extends SenderBufferBase {
+ /**
+ * Creates a new SenderBufferV1 instance.
+ * @param options - Sender configuration options
+ */
constructor(options: SenderOptions) {
super(options);
}
+ /**
+ * Write a float column with its value into the buffer using v1 format.
+ * @param name - Column name
+ * @param value - Float value to write
+ * @returns Reference to this sender buffer for method chaining
+ */
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
name,
@@ -22,6 +36,10 @@ class SenderBufferV1 extends SenderBufferBase {
return this;
}
+ /**
+ * Array columns are not supported in protocol v1.
+ * @throws Error indicating arrays are not supported in v1
+ */
arrayColumn(): SenderBuffer {
throw new Error("Arrays are not supported in protocol v1");
}
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index f9fd602..0bd0318 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -4,19 +4,36 @@ import { SenderBuffer } from "./index";
import { SenderBufferBase } from "./base";
import { getDimensions, validateArray } from "../utils";
+// Column type constants for protocol v2.
const COLUMN_TYPE_DOUBLE: number = 10;
const COLUMN_TYPE_NULL: number = 33;
+// Entity type constants for protocol v2.
const ENTITY_TYPE_ARRAY: number = 14;
const ENTITY_TYPE_DOUBLE: number = 16;
+// ASCII code for equals sign used in binary protocol.
const EQUALS_SIGN: number = "=".charCodeAt(0);
+/**
+ * Buffer implementation for QuestDB line protocol version 2.
+ * Supports all column types including arrays with binary encoding for doubles.
+ */
class SenderBufferV2 extends SenderBufferBase {
+ /**
+ * Creates a new SenderBufferV2 instance.
+ * @param options - Sender configuration options
+ */
constructor(options: SenderOptions) {
super(options);
}
+ /**
+ * Write a float column with its value into the buffer using v2 binary format.
+ * @param name - Column name
+ * @param value - Float value to write
+ * @returns Reference to this sender buffer for method chaining
+ */
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
name,
@@ -32,6 +49,13 @@ class SenderBufferV2 extends SenderBufferBase {
return this;
}
+ /**
+ * Write an array column with its values into the buffer using v2 format.
+ * @param name - Column name
+ * @param value - Array values to write (currently supports double arrays)
+ * @returns Reference to this sender buffer for method chaining
+ * @throws Error if value is not an array when provided
+ */
arrayColumn(name: string, value: unknown[]): SenderBuffer {
const dimensions = getDimensions(value);
const type = validateArray(value, dimensions);
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index cd11d09..850acce 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -11,9 +11,18 @@ import { TimestampUnit } from "../utils";
import { SenderBufferV1 } from "./bufferv1";
import { SenderBufferV2 } from "./bufferv2";
+// Default initial buffer size in bytes (64 KB).
const DEFAULT_BUFFER_SIZE = 65536; // 64 KB
+
+// Default maximum buffer size in bytes (100 MB).
const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
+/**
+ * Factory function to create a SenderBuffer instance based on the protocol version.
+ * @param options - Sender configuration options
+ * @returns A SenderBuffer instance appropriate for the specified protocol version
+ * @throws Error if protocol version is not specified or is unsupported
+ */
function createBuffer(options: SenderOptions): SenderBuffer {
switch (options.protocol_version) {
case PROTOCOL_VERSION_V2:
@@ -34,11 +43,10 @@ function createBuffer(options: SenderOptions): SenderBuffer {
}
}
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * If no custom agent is configured, the Sender will use its own agent which overrides some default values
- * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
- *
+/**
+ * Interface for QuestDB line protocol buffer operations.
+ * Provides methods to write table names, columns, and timestamps into a buffer
+ * for efficient data ingestion.
*/
interface SenderBuffer {
/**
@@ -109,6 +117,13 @@ interface SenderBuffer {
*/
floatColumn(name: string, value: number): SenderBuffer;
+ /**
+ * Write an array column with its values into the buffer of the sender.
+ *
+ * @param {string} name - Column name.
+ * @param {unknown[]} value - Array values to be written.
+ * @return {SenderBuffer} Returns with a reference to this sender buffer.
+ */
arrayColumn(name: string, value: unknown[]): SenderBuffer;
/**
diff --git a/src/logging.ts b/src/logging.ts
index 751629e..743c95a 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -1,3 +1,5 @@
+// Log level configuration with console methods and criticality levels.
+// Higher criticality values indicate more important messages.
const LOG_LEVELS = {
error: { log: console.error, criticality: 3 },
warn: { log: console.warn, criticality: 2 },
@@ -5,8 +7,14 @@ const LOG_LEVELS = {
debug: { log: console.debug, criticality: 0 },
};
+// Default logging criticality level. Messages with criticality below this level are ignored.
const DEFAULT_CRITICALITY = LOG_LEVELS.info.criticality;
+/**
+ * Logger function type definition.
+ * @param level - The log level for the message
+ * @param message - The message to log, either a string or Error object
+ */
type Logger = (
level: "error" | "warn" | "info" | "debug",
message: string | Error,
diff --git a/src/options.ts b/src/options.ts
index 1eb0b4e..cadf3c1 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -44,15 +44,17 @@ type DeprecatedOptions = {
* Properties of the object are initialized through a configuration string.
* The configuration string has the following format: <protocol>::<key>=<value><key>=<value>...;
* The keys are case-sensitive, the trailing semicolon is optional.
- * The values are validated, and an error is thrown if the format is invalid.
+ * The values are validated and an error is thrown if the format is invalid.
*
* Connection and protocol options
*
*
protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
* When https or tcps used, the connection is secured with TLS encryption.
*
- *
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used to communicate with the server.
- * When https or tcps used, the connection is secured with TLS encryption.
+ *
protocol_version: enum, accepted values: auto, 1, 2 - The line protocol version used for data serialization.
+ * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and supports array columns.
+ * When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
+ * TCP/TCPS connections default to version 1. Version 2 provides better performance for numeric data and enables array column support.
*
*
addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
* If no port is specified, a default will be used.
@@ -79,8 +81,8 @@ type DeprecatedOptions = {
* TLS options
*
*
tls_verify: enum, accepted values: on, unsafe_off - When the HTTPS or TCPS protocols are selected, TLS encryption is used.
- * By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to off. This is useful
- * non-production environments where self-signed certificates might be used, but should be avoided in production if possible.
+ * By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to unsafe_off.
+ * This is useful in non-production environments where self-signed certificates might be used, but should be avoided in production if possible.
*
*
tls_ca: string - Path to a file containing the root CA's certificate in PEM format.
* Can be useful when self-signed certificates are used, otherwise should not be set.
@@ -92,9 +94,9 @@ type DeprecatedOptions = {
*
auto_flush: enum, accepted values: on, off - The Sender automatically flushes the buffer by default. This can be switched off
* by setting this option to off.
* When disabled, the flush() method of the Sender has to be called explicitly to make sure data is sent to the server.
- * Manual buffer flushing can be useful, especially when we want to use transactions. When the HTTP protocol is used, each flush results in a single HTTP
- * request, which becomes a single transaction on the server side. The transaction either succeeds, and all rows sent in the request are
- * inserted; or it fails, and none of the rows make it into the database.
+ * Manual buffer flushing can be useful, especially when we want to control transaction boundaries.
+ * When the HTTP protocol is used, each flush results in a single HTTP request, which becomes a single transaction on the server side.
+ * The transaction either succeeds, and all rows sent in the request are inserted; or it fails, and none of the rows make it into the database.
*
*
auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
* The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol.
@@ -190,7 +192,8 @@ class SenderOptions {
* - 'log' is a logging function used by the Sender.
* Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
* - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
- * A http.Agent or https.Agent object is expected.
+ * An undici.Agent object is expected.
+ * If the legacy HTTP transport is used, a http.Agent or https.Agent object is expected.
*/
constructor(configurationString: string, extraOptions?: ExtraOptions) {
parseConfigurationString(this, configurationString);
diff --git a/src/sender.ts b/src/sender.ts
index a5767d9..2b39b8b 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -8,9 +8,19 @@ import { isBoolean, isInteger, TimestampUnit } from "./utils";
const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
- * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
+ * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
+ * The client supports multiple transport protocols.
+ *
+ * Transport Options:
+ *
+ *
HTTP (http://): Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes.
+ * Recommended for most use cases due to superior error handling and debugging capabilities. Uses Undici library by default for high performance.
+ *
HTTPS (https://): Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication.
+ * Supports certificate validation and custom CA certificates.
TCPS (tcps://): Secure TCP transport with TLS encryption.
+ *
+ *
*
* The client supports authentication.
* Authentication details can be passed to the Sender in its configuration options.
@@ -26,6 +36,12 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
* such as Nginx to enable encryption.
*
*
+ * The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
+ * serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
+ * performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS
+ * by setting the protocol_version to 'auto' (default behavior).
+ *
+ *
* The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server.
* Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum
* buffer sizes can also be set.
@@ -39,6 +55,22 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
* the SenderOptions documentation.
*
+ * HTTP Transport Implementation:
+ * By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling.
+ * For compatibility or specific requirements, you can enable the legacy HTTP transport using Node.js built-in modules
+ * by setting legacy_http=on in the configuration string. The legacy transport provides the same functionality
+ * but uses Node.js http/https modules instead of Undici.
+ *
+ *
* Extra options can be provided to the Sender in the extraOptions configuration object.
* A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object.
* The logger implementation provides the option to direct log messages to the same place where the host application's
@@ -234,6 +266,16 @@ class Sender {
return this;
}
+ /**
+ * Write an array column with its values into the buffer of the sender.
+ * Note: Array columns are only supported in protocol version 2. If using protocol version 1,
+ * this method will throw an error.
+ *
+ * @param {string} name - Column name.
+ * @param {unknown[]} value - Array values to be written. Currently supports arrays of numbers.
+ * @return {Sender} Returns with a reference to this sender.
+ * @throws {Error} If protocol version 1 is used, as arrays are not supported.
+ */
arrayColumn(name: string, value: unknown[]): Sender {
this.buffer.arrayColumn(name, value);
return this;
diff --git a/src/transport/http/base.ts b/src/transport/http/base.ts
index cdaf5f3..102bca8 100644
--- a/src/transport/http/base.ts
+++ b/src/transport/http/base.ts
@@ -7,33 +7,28 @@ import { SenderOptions, HTTP, HTTPS } from "../../options";
import { SenderTransport } from "../index";
import { isBoolean, isInteger } from "../../utils";
-const HTTP_NO_CONTENT = 204; // success
+// HTTP status code for successful request with no content.
+const HTTP_NO_CONTENT = 204;
+// Default number of rows that trigger auto-flush for HTTP transport.
const DEFAULT_HTTP_AUTO_FLUSH_ROWS = 75000;
-const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400; // 100 KB/sec
-const DEFAULT_REQUEST_TIMEOUT = 10000; // 10 sec
-const DEFAULT_RETRY_TIMEOUT = 10000; // 10 sec
-
-/*
-We are retrying on the following response codes (copied from the Rust client):
-500: Internal Server Error
-503: Service Unavailable
-504: Gateway Timeout
-
-// Unofficial extensions
-507: Insufficient Storage
-509: Bandwidth Limit Exceeded
-523: Origin is Unreachable
-524: A Timeout Occurred
-529: Site is overloaded
-599: Network Connect Timeout Error
-*/
+// Default minimum throughput for HTTP requests (100 KB/sec).
+const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400;
+
+// Default request timeout in milliseconds (10 seconds).
+const DEFAULT_REQUEST_TIMEOUT = 10000;
+
+// Default retry timeout in milliseconds (10 seconds).
+const DEFAULT_RETRY_TIMEOUT = 10000;
+
+// HTTP status codes that should trigger request retries.
+// Includes server errors and gateway timeouts that may be transient.
const RETRIABLE_STATUS_CODES = [500, 503, 504, 507, 509, 523, 524, 529, 599];
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
+/**
+ * Abstract base class for HTTP-based transport implementations.
+ * Provides common configuration and functionality for HTTP and HTTPS protocols.
*/
abstract class HttpTransportBase implements SenderTransport {
protected readonly secure: boolean;
@@ -53,6 +48,11 @@ abstract class HttpTransportBase implements SenderTransport {
protected readonly log: Logger;
+ /**
+ * Creates a new HttpTransportBase instance.
+ * @param options - Sender configuration options including connection and authentication details
+ * @throws Error if required protocol or host options are missing
+ */
protected constructor(options: SenderOptions) {
if (!options || !options.protocol) {
throw new Error("The 'protocol' option is mandatory");
@@ -99,16 +99,34 @@ abstract class HttpTransportBase implements SenderTransport {
}
}
+ /**
+ * HTTP transport does not require explicit connection establishment.
+ * @throws Error indicating connect is not required for HTTP transport
+ */
connect(): Promise {
throw new Error("'connect()' is not required for HTTP transport");
}
+ /**
+ * HTTP transport does not require explicit connection closure.
+ * @returns Promise that resolves immediately
+ */
async close(): Promise {}
+ /**
+ * Gets the default auto-flush row count for HTTP transport.
+ * @returns Default number of rows that trigger auto-flush
+ */
getDefaultAutoFlushRows(): number {
return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
}
+ /**
+ * Sends data to the QuestDB server via HTTP.
+ * Must be implemented by concrete HTTP transport classes.
+ * @param data - Buffer containing the data to send
+ * @returns Promise resolving to true if data was sent successfully
+ */
abstract send(data: Buffer): Promise;
}
diff --git a/src/transport/http/legacy.ts b/src/transport/http/legacy.ts
index e51a88f..613802f 100644
--- a/src/transport/http/legacy.ts
+++ b/src/transport/http/legacy.ts
@@ -10,19 +10,21 @@ import {
HTTP_NO_CONTENT,
} from "./base";
-// default options for HTTP agent
-// - persistent connections with 1 minute idle timeout, server side has 5 minutes set by default
-// - max open connections is set to 256, same as server side default
+/**
+ * Default configuration for HTTP agents.
+ * - Persistent connections with 1 minute idle timeout
+ * - Maximum of 256 open connections (matching server default)
+ */
const DEFAULT_HTTP_AGENT_CONFIG = {
maxSockets: 256,
keepAlive: true,
timeout: 60000, // 1 min
};
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
- * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
+/**
+ * Legacy HTTP transport implementation using Node.js built-in http/https modules.
+ * Provides HTTP requests with manual retry logic and connection pooling.
+ * Supports both HTTP and HTTPS protocols with configurable authentication.
*/
class HttpTransport extends HttpTransportBase {
private static DEFAULT_HTTP_AGENT: http.Agent;
@@ -31,10 +33,10 @@ class HttpTransport extends HttpTransportBase {
private readonly agent: http.Agent | https.Agent;
/**
- * Creates an instance of Sender.
+ * Creates a new HttpTransport instance using legacy Node.js HTTP modules.
*
- * @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
+ * @param options - Sender configuration object containing connection details
+ * @throws Error if the protocol is not 'http' or 'https'
*/
constructor(options: SenderOptions) {
super(options);
@@ -59,6 +61,14 @@ class HttpTransport extends HttpTransportBase {
}
}
+ /**
+ * Sends data to QuestDB using HTTP POST with manual retry logic.
+ * @param data - Buffer containing line protocol data to send
+ * @param retryBegin - Internal parameter for tracking retry start time
+ * @param retryInterval - Internal parameter for tracking retry intervals
+ * @returns Promise resolving to true if data was sent successfully
+ * @throws Error if request fails after all retries or times out
+ */
send(data: Buffer, retryBegin = -1, retryInterval = -1): Promise {
const request = this.secure ? https.request : http.request;
@@ -146,6 +156,11 @@ class HttpTransport extends HttpTransportBase {
});
}
+ /**
+ * Creates HTTP request options based on configuration.
+ * @param timeoutMillis - Request timeout in milliseconds
+ * @returns HTTP or HTTPS request options object
+ */
private createRequestOptions(
timeoutMillis: number,
): http.RequestOptions | https.RequestOptions {
@@ -163,8 +178,9 @@ class HttpTransport extends HttpTransportBase {
}
/**
- * @ignore
- * @return {http.Agent} Returns the default http agent.
+ * Gets or creates the default HTTP agent with standard configuration.
+ * Uses a singleton pattern to reuse the same agent across instances.
+ * @returns The default HTTP agent instance
*/
private static getDefaultHttpAgent(): http.Agent {
if (!HttpTransport.DEFAULT_HTTP_AGENT) {
@@ -176,8 +192,9 @@ class HttpTransport extends HttpTransportBase {
}
/**
- * @ignore
- * @return {https.Agent} Returns the default https agent.
+ * Gets or creates the default HTTPS agent with standard configuration.
+ * Uses a singleton pattern to reuse the same agent across instances.
+ * @returns The default HTTPS agent instance
*/
private static getDefaultHttpsAgent(): https.Agent {
if (!HttpTransport.DEFAULT_HTTPS_AGENT) {
@@ -189,6 +206,11 @@ class HttpTransport extends HttpTransportBase {
}
}
+/**
+ * Determines if an HTTP status code should trigger a retry.
+ * @param statusCode - HTTP status code to check
+ * @returns True if the status code indicates a retryable error
+ */
function isRetryable(statusCode: number) {
return RETRIABLE_STATUS_CODES.includes(statusCode);
}
diff --git a/src/transport/http/undici.ts b/src/transport/http/undici.ts
index c6545cd..e091f6d 100644
--- a/src/transport/http/undici.ts
+++ b/src/transport/http/undici.ts
@@ -10,6 +10,10 @@ import {
HTTP_NO_CONTENT,
} from "./base";
+/**
+ * Default HTTP options for the Undici agent.
+ * Configures keep-alive connections with 60-second timeout and single request pipelining.
+ */
const DEFAULT_HTTP_OPTIONS: Agent.Options = {
connect: {
keepAlive: true,
@@ -18,11 +22,10 @@ const DEFAULT_HTTP_OPTIONS: Agent.Options = {
keepAliveTimeout: 60000, // 1 minute
};
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
- * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
- *
+/**
+ * HTTP transport implementation using the Undici library.
+ * Provides high-performance HTTP requests with connection pooling and retry logic.
+ * Supports both HTTP and HTTPS protocols with configurable authentication.
*/
class UndiciTransport extends HttpTransportBase {
private static DEFAULT_HTTP_AGENT: Agent;
@@ -31,10 +34,10 @@ class UndiciTransport extends HttpTransportBase {
private readonly dispatcher: RetryAgent;
/**
- * Creates an instance of Sender.
+ * Creates a new UndiciTransport instance.
*
- * @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
+ * @param options - Sender configuration object containing connection and retry settings
+ * @throws Error if the protocol is not 'http' or 'https'
*/
constructor(options: SenderOptions) {
super(options);
@@ -89,6 +92,12 @@ class UndiciTransport extends HttpTransportBase {
});
}
+ /**
+ * Sends data to QuestDB using HTTP POST with retry logic and authentication.
+ * @param data - Buffer containing line protocol data to send
+ * @returns Promise resolving to true if data was sent successfully
+ * @throws Error if request fails after all retries or times out
+ */
async send(data: Buffer): Promise {
const headers: Record = {};
if (this.token) {
@@ -144,8 +153,9 @@ class UndiciTransport extends HttpTransportBase {
}
/**
- * @ignore
- * @return {Agent} Returns the default http agent.
+ * Gets or creates the default HTTP agent with standard configuration.
+ * Uses a singleton pattern to reuse the same agent across instances.
+ * @returns The default Undici agent instance
*/
private static getDefaultHttpAgent(): Agent {
if (!UndiciTransport.DEFAULT_HTTP_AGENT) {
diff --git a/src/transport/index.ts b/src/transport/index.ts
index d0843f1..2925e29 100644
--- a/src/transport/index.ts
+++ b/src/transport/index.ts
@@ -6,13 +6,43 @@ import { UndiciTransport } from "./http/undici";
import { TcpTransport } from "./tcp";
import { HttpTransport } from "./http/legacy";
+/**
+ * Interface for QuestDB transport implementations.
+ * Defines the contract for different transport protocols (HTTP/HTTPS/TCP/TCPS).
+ */
interface SenderTransport {
+ /**
+ * Establishes a connection to the database server.
+ * @returns Promise resolving to true if connection is successful
+ */
connect(): Promise;
+
+ /**
+ * Sends buffered data to the database server.
+ * @param data - Buffer containing the data to send
+ * @returns Promise resolving to true if data was sent successfully
+ */
send(data: Buffer): Promise;
+
+ /**
+ * Closes the connection to the database server.
+ * @returns Promise that resolves when the connection is closed
+ */
close(): Promise;
+
+ /**
+ * Gets the default number of rows that trigger auto-flush for this transport.
+ * @returns Default auto-flush row count
+ */
getDefaultAutoFlushRows(): number;
}
+/**
+ * Factory function to create appropriate transport instance based on configuration.
+ * @param options - Sender configuration options including protocol and connection details
+ * @returns Transport instance appropriate for the specified protocol
+ * @throws Error if protocol or host options are missing or invalid
+ */
function createTransport(options: SenderOptions): SenderTransport {
if (!options || !options.protocol) {
throw new Error("The 'protocol' option is mandatory");
diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts
index b29e367..b72c729 100644
--- a/src/transport/tcp.ts
+++ b/src/transport/tcp.ts
@@ -10,58 +10,20 @@ import { SenderOptions, TCP, TCPS } from "../options";
import { SenderTransport } from "./index";
import { isBoolean } from "../utils";
+// Default number of rows that trigger auto-flush for TCP transport.
const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600;
-// an arbitrary public key, not used in authentication
-// only used to construct a valid JWK token which is accepted by the crypto API
+// Arbitrary public key coordinates used to construct valid JWK tokens.
+// These are not used for actual authentication, only for crypto API compatibility.
const PUBLIC_KEY = {
x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc",
y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg",
};
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
- * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
- *
- * The client supports authentication.
- * Authentication details can be passed to the Sender in its configuration options.
- * The client supports Basic username/password and Bearer token authentication methods when used with HTTP protocol,
- * and JWK token authentication when ingesting data via TCP.
- * Please, note that authentication is enabled by default in QuestDB Enterprise only.
- * Details on how to configure authentication in the open source version of
- * QuestDB: {@link https://questdb.io/docs/reference/api/ilp/authenticate}
- *
- *
- * The client also supports TLS encryption for both, HTTP and TCP transports to provide a secure connection.
- * Please, note that the open source version of QuestDB does not support TLS, and requires an external reverse-proxy,
- * such as Nginx to enable encryption.
- *
- *
- * The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server.
- * Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum
- * buffer sizes can also be set.
- *
- *
- * It is recommended that the Sender is created by using one of the static factory methods,
- * Sender.fromConfig(configString, extraOptions) or Sender.fromEnv(extraOptions).
- * If the Sender is created via its constructor, at least the SenderOptions configuration object should be
- * initialized from a configuration string to make sure that the parameters are validated.
- * Detailed description of the Sender's configuration options can be found in
- * the SenderOptions documentation.
- *
- *
- * Extra options can be provided to the Sender in the extraOptions configuration object.
- * A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object.
- * The logger implementation provides the option to direct log messages to the same place where the host application's
- * log is saved. The default logger writes to the console.
- * The custom HTTP(S) agent option becomes handy if there is a need to modify the default options set for the
- * HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be
- * passed to the Sender with keepAlive set to false.
- * For example: Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})
- * If no custom agent is configured, the Sender will use its own agent which overrides some default values
- * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
- *
+/**
+ * TCP transport implementation for QuestDB line protocol.
+ * Supports both TCP and TCPS (TLS-encrypted) connections with JWK token authentication.
+ * Provides persistent connections with challenge-response authentication flow.
*/
class TcpTransport implements SenderTransport {
private readonly secure: boolean;
@@ -77,10 +39,10 @@ class TcpTransport implements SenderTransport {
private readonly jwk: Record;
/**
- * Creates an instance of Sender.
+ * Creates a new TcpTransport instance.
*
- * @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
+ * @param options - Sender configuration object containing connection and authentication details
+ * @throws Error if required options are missing or protocol is not 'tcp' or 'tcps'
*/
constructor(options: SenderOptions) {
if (!options || !options.protocol) {
@@ -120,9 +82,11 @@ class TcpTransport implements SenderTransport {
}
/**
- * Creates a TCP connection to the database.
+ * Creates a TCP connection to the database with optional authentication.
+ * Handles both plain TCP and TLS-encrypted connections.
*
- * @return {Promise} Resolves to true if the client is connected.
+ * @returns Promise resolving to true if the connection is established successfully
+ * @throws Error if connection fails or authentication is rejected
*/
connect(): Promise {
const connOptions: net.NetConnectOpts | tls.ConnectionOptions = {
@@ -193,6 +157,12 @@ class TcpTransport implements SenderTransport {
});
}
+ /**
+ * Sends data over the established TCP connection.
+ * @param data - Buffer containing the data to send
+ * @returns Promise resolving to true if data was sent successfully
+ * @throws Error if the connection is not established
+ */
send(data: Buffer): Promise {
if (!this.socket || this.socket.destroyed) {
throw new Error("TCP transport is not connected");
@@ -211,6 +181,7 @@ class TcpTransport implements SenderTransport {
/**
* Closes the TCP connection to the database.
* Data sitting in the Sender's buffer will be lost unless flush() is called before close().
+ * @returns Promise that resolves when the connection is closed
*/
async close(): Promise {
if (this.socket) {
@@ -222,10 +193,19 @@ class TcpTransport implements SenderTransport {
}
}
+ /**
+ * Gets the default auto-flush row count for TCP transport.
+ * @returns Default number of rows that trigger auto-flush
+ */
getDefaultAutoFlushRows(): number {
return DEFAULT_TCP_AUTO_FLUSH_ROWS;
}
+ /**
+ * Handles the JWK token authentication challenge-response flow.
+ * @param challenge - Challenge buffer received from the server
+ * @returns Promise resolving to true if authentication is successful
+ */
private async authenticate(challenge: Buffer): Promise {
// Check for trailing \n which ends the challenge
if (challenge.subarray(-1).readInt8() === 10) {
@@ -256,6 +236,9 @@ class TcpTransport implements SenderTransport {
}
}
+// Constructs authentication configuration from username/token options.
+// @param options - Sender options that may contain authentication details
+// @throws Error if username or token is missing when authentication is intended
function constructAuth(options: SenderOptions) {
if (!options.username && !options.token && !options.password) {
// no intention to authenticate
@@ -274,6 +257,10 @@ function constructAuth(options: SenderOptions) {
};
}
+// Constructs a JWK (JSON Web Key) object for cryptographic authentication.
+// @param options - Sender options containing authentication configuration
+// @returns JWK object with key ID, private key, and public key coordinates
+// @throws Error if required authentication properties are missing or invalid
function constructJwk(options: SenderOptions) {
if (options.auth) {
if (!options.auth.keyId) {
diff --git a/src/utils.ts b/src/utils.ts
index 095bcf6..56dd90a 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,17 +1,41 @@
+/**
+ * Supported primitive types for QuestDB arrays.
+ */
type ArrayPrimitive = "number" | "boolean" | "string" | null;
+/**
+ * Supported timestamp units for QuestDB operations.
+ */
type TimestampUnit = "ns" | "us" | "ms";
+/**
+ * Type guard to check if a value is a boolean.
+ * @param value - The value to check
+ * @returns True if the value is a boolean, false otherwise
+ */
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
+/**
+ * Type guard to check if a value is an integer within specified bounds.
+ * @param value - The value to check
+ * @param lowerBound - The minimum allowed value (inclusive)
+ * @returns True if the value is an integer >= lowerBound, false otherwise
+ */
function isInteger(value: unknown, lowerBound: number): value is number {
return (
typeof value === "number" && Number.isInteger(value) && value >= lowerBound
);
}
+/**
+ * Converts a timestamp from the specified unit to microseconds.
+ * @param timestamp - The timestamp value as a bigint
+ * @param unit - The source timestamp unit
+ * @returns The timestamp converted to microseconds
+ * @throws Error if the timestamp unit is unknown
+ */
function timestampToMicros(timestamp: bigint, unit: TimestampUnit) {
switch (unit) {
case "ns":
@@ -25,6 +49,13 @@ function timestampToMicros(timestamp: bigint, unit: TimestampUnit) {
}
}
+/**
+ * Converts a timestamp from the specified unit to nanoseconds.
+ * @param timestamp - The timestamp value as a bigint
+ * @param unit - The source timestamp unit
+ * @returns The timestamp converted to nanoseconds
+ * @throws Error if the timestamp unit is unknown
+ */
function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
switch (unit) {
case "ns":
@@ -38,6 +69,12 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
}
}
+/**
+ * Analyzes the dimensions of a nested array structure.
+ * @param data - The array to analyze
+ * @returns Array of dimension sizes at each nesting level
+ * @throws Error if any dimension has zero length
+ */
function getDimensions(data: unknown) {
const dimensions: number[] = [];
while (Array.isArray(data)) {
@@ -105,6 +142,13 @@ function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
return expectedType;
}
+/**
+ * Fetches JSON data from a URL with error handling.
+ * @template T - The expected type of the JSON response
+ * @param url - The URL to fetch from
+ * @returns Promise resolving to the parsed JSON data
+ * @throws Error if the request fails or returns a non-OK status
+ */
async function fetchJson(url: string): Promise {
let response: globalThis.Response;
try {
From 83455aab6defefc0eefa175762172543f8bbf041 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 21:40:31 +0100
Subject: [PATCH 14/38] formatting
---
test/sender.transport.test.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts
index 388c875..6bf27ea 100644
--- a/test/sender.transport.test.ts
+++ b/test/sender.transport.test.ts
@@ -587,8 +587,8 @@ describe("Sender TCP suite", function () {
/^Connection to .*1:9088 is closed$/,
];
const log = (
- level: "error" | "warn" | "info" | "debug",
- message: string,
+ level: "error" | "warn" | "info" | "debug",
+ message: string,
) => {
if (level !== "debug") {
expect(message).toMatch(expectedMessages.shift());
@@ -610,9 +610,9 @@ describe("Sender TCP suite", function () {
// assert that only the first line was sent
await assertSentData(
- proxy,
- false,
- "test,location=us temperature=17.1 1658484765000000000\n",
+ proxy,
+ false,
+ "test,location=us temperature=17.1 1658484765000000000\n",
);
await sender.close();
await proxy.stop();
From 50adf5c5c08e11002e44e9edfc52ed1d59e7e46e Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 21:55:01 +0100
Subject: [PATCH 15/38] fix protocol_version doc
---
src/options.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/options.ts b/src/options.ts
index b781900..bba203f 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -51,8 +51,10 @@ type DeprecatedOptions = {
*
protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
* When https or tcps used, the connection is secured with TLS encryption.
*
- *
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used to communicate with the server.
- * When https or tcps used, the connection is secured with TLS encryption.
+ *
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
+ * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles.
+ * When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
+ * TCP/TCPS connections default to version 1.
*
*
addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
* If no port is specified, a default will be used.
From e5a7deed8bbae094241284ff8aecd4b6fc15764f Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 22:06:37 +0100
Subject: [PATCH 16/38] fix merge fallout
---
src/transport/http/legacy.ts | 196 -----------------------------------
src/transport/http/stdlib.ts | 1 -
2 files changed, 197 deletions(-)
delete mode 100644 src/transport/http/legacy.ts
diff --git a/src/transport/http/legacy.ts b/src/transport/http/legacy.ts
deleted file mode 100644
index e51a88f..0000000
--- a/src/transport/http/legacy.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-// @ts-check
-import http from "http";
-import https from "https";
-import { Buffer } from "node:buffer";
-
-import { SenderOptions, HTTP, HTTPS } from "../../options";
-import {
- HttpTransportBase,
- RETRIABLE_STATUS_CODES,
- HTTP_NO_CONTENT,
-} from "./base";
-
-// default options for HTTP agent
-// - persistent connections with 1 minute idle timeout, server side has 5 minutes set by default
-// - max open connections is set to 256, same as server side default
-const DEFAULT_HTTP_AGENT_CONFIG = {
- maxSockets: 256,
- keepAlive: true,
- timeout: 60000, // 1 min
-};
-
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * The supported protocols are HTTP and TCP. HTTP is preferred as it provides feedback in the HTTP response.
- * Based on benchmarks HTTP also provides higher throughput, if configured to ingest data in bigger batches.
- */
-class HttpTransport extends HttpTransportBase {
- private static DEFAULT_HTTP_AGENT: http.Agent;
- private static DEFAULT_HTTPS_AGENT: https.Agent;
-
- private readonly agent: http.Agent | https.Agent;
-
- /**
- * Creates an instance of Sender.
- *
- * @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
- */
- constructor(options: SenderOptions) {
- super(options);
-
- switch (options.protocol) {
- case HTTP:
- this.agent =
- options.agent instanceof http.Agent
- ? options.agent
- : HttpTransport.getDefaultHttpAgent();
- break;
- case HTTPS:
- this.agent =
- options.agent instanceof https.Agent
- ? options.agent
- : HttpTransport.getDefaultHttpsAgent();
- break;
- default:
- throw new Error(
- "The 'protocol' has to be 'http' or 'https' for the HTTP transport",
- );
- }
- }
-
- send(data: Buffer, retryBegin = -1, retryInterval = -1): Promise {
- const request = this.secure ? https.request : http.request;
-
- const timeoutMillis =
- (data.length / this.requestMinThroughput) * 1000 + this.requestTimeout;
- const options = this.createRequestOptions(timeoutMillis);
-
- return new Promise((resolve, reject) => {
- let statusCode = -1;
- const req = request(options, (response) => {
- statusCode = response.statusCode;
-
- const body = [];
- response
- .on("data", (chunk) => {
- body.push(chunk);
- })
- .on("error", (err) => {
- this.log("error", `resp err=${err}`);
- });
-
- if (statusCode === HTTP_NO_CONTENT) {
- response.on("end", () => {
- if (body.length > 0) {
- this.log(
- "warn",
- `Unexpected message from server: ${Buffer.concat(body)}`,
- );
- }
- resolve(true);
- });
- } else {
- req.destroy(
- new Error(
- `HTTP request failed, statusCode=${statusCode}, error=${Buffer.concat(body)}`,
- ),
- );
- }
- });
-
- if (this.token) {
- req.setHeader("Authorization", `Bearer ${this.token}`);
- } else if (this.username && this.password) {
- req.setHeader(
- "Authorization",
- `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`,
- );
- }
-
- req.on("timeout", () => {
- // set a retryable error code
- statusCode = 524;
- req.destroy(
- new Error("HTTP request timeout, no response from server in time"),
- );
- });
- req.on("error", (err) => {
- // if the error is thrown while the request is sent, statusCode is -1 => no retry
- // request timeout comes through with statusCode 524 => retry
- // if the error is thrown while the response is processed, the statusCode is taken from the response => retry depends on statusCode
- if (isRetryable(statusCode) && this.retryTimeout > 0) {
- if (retryBegin < 0) {
- retryBegin = Date.now();
- retryInterval = 10;
- } else {
- const elapsed = Date.now() - retryBegin;
- if (elapsed > this.retryTimeout) {
- reject(err);
- return;
- }
- }
- const jitter = Math.floor(Math.random() * 10) - 5;
- setTimeout(() => {
- retryInterval = Math.min(retryInterval * 2, 1000);
- this.send(data, retryBegin, retryInterval)
- .then(() => resolve(true))
- .catch((e) => reject(e));
- }, retryInterval + jitter);
- } else {
- reject(err);
- }
- });
- req.write(data, (err) => (err ? reject(err) : () => {}));
- req.end();
- });
- }
-
- private createRequestOptions(
- timeoutMillis: number,
- ): http.RequestOptions | https.RequestOptions {
- return {
- //protocol: this.secure ? "https:" : "http:",
- hostname: this.host,
- port: this.port,
- agent: this.agent,
- path: "/write?precision=n",
- method: "POST",
- timeout: timeoutMillis,
- rejectUnauthorized: this.secure && this.tlsVerify,
- ca: this.secure ? this.tlsCA : undefined,
- };
- }
-
- /**
- * @ignore
- * @return {http.Agent} Returns the default http agent.
- */
- private static getDefaultHttpAgent(): http.Agent {
- if (!HttpTransport.DEFAULT_HTTP_AGENT) {
- HttpTransport.DEFAULT_HTTP_AGENT = new http.Agent(
- DEFAULT_HTTP_AGENT_CONFIG,
- );
- }
- return HttpTransport.DEFAULT_HTTP_AGENT;
- }
-
- /**
- * @ignore
- * @return {https.Agent} Returns the default https agent.
- */
- private static getDefaultHttpsAgent(): https.Agent {
- if (!HttpTransport.DEFAULT_HTTPS_AGENT) {
- HttpTransport.DEFAULT_HTTPS_AGENT = new https.Agent(
- DEFAULT_HTTP_AGENT_CONFIG,
- );
- }
- return HttpTransport.DEFAULT_HTTPS_AGENT;
- }
-}
-
-function isRetryable(statusCode: number) {
- return RETRIABLE_STATUS_CODES.includes(statusCode);
-}
-
-export { HttpTransport, HttpTransportBase };
diff --git a/src/transport/http/stdlib.ts b/src/transport/http/stdlib.ts
index 8717c41..15a92ad 100644
--- a/src/transport/http/stdlib.ts
+++ b/src/transport/http/stdlib.ts
@@ -33,7 +33,6 @@ class HttpTransport extends HttpTransportBase {
/**
* Creates a new HttpTransport instance using Node.js HTTP modules.
- *
* @param options - Sender configuration object containing connection details
* @throws Error if the protocol is not 'http' or 'https'
*/
From dc29488fc9f15ecd32caf17077a94ec7f78cd6ae Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 22:10:12 +0100
Subject: [PATCH 17/38] fix merge fallout
---
src/transport/http/stdlib.ts | 1 +
src/transport/tcp.ts | 3 +--
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/transport/http/stdlib.ts b/src/transport/http/stdlib.ts
index 15a92ad..6861df8 100644
--- a/src/transport/http/stdlib.ts
+++ b/src/transport/http/stdlib.ts
@@ -33,6 +33,7 @@ class HttpTransport extends HttpTransportBase {
/**
* Creates a new HttpTransport instance using Node.js HTTP modules.
+ *
* @param options - Sender configuration object containing connection details
* @throws Error if the protocol is not 'http' or 'https'
*/
diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts
index 812963c..bd2c7c0 100644
--- a/src/transport/tcp.ts
+++ b/src/transport/tcp.ts
@@ -175,8 +175,7 @@ class TcpTransport implements SenderTransport {
}
/**
- * Closes the TCP connection to the database.
- * Data sitting in the Sender's buffer will be lost unless flush() is called before close().
+ * Closes the TCP connection to the database.
*/
async close(): Promise {
if (this.socket) {
From 30213960551002a4343fa77362f80f0b975fce2c Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 22:10:47 +0100
Subject: [PATCH 18/38] fix merge fallout
---
src/transport/http/stdlib.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/transport/http/stdlib.ts b/src/transport/http/stdlib.ts
index 6861df8..8717c41 100644
--- a/src/transport/http/stdlib.ts
+++ b/src/transport/http/stdlib.ts
@@ -33,7 +33,7 @@ class HttpTransport extends HttpTransportBase {
/**
* Creates a new HttpTransport instance using Node.js HTTP modules.
- *
+ *
* @param options - Sender configuration object containing connection details
* @throws Error if the protocol is not 'http' or 'https'
*/
From 0ad1ecaee11ab3f663f4374612ec88542095e105 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 22:50:13 +0100
Subject: [PATCH 19/38] js doc for buffer
---
src/buffer/base.ts | 50 ++++++++++++++++++++----------------------
src/buffer/bufferv1.ts | 11 ++++++++++
src/buffer/bufferv2.ts | 11 ++++++++++
src/buffer/index.ts | 36 +++++++++++++++---------------
src/transport/tcp.ts | 2 +-
src/utils.ts | 7 ++++++
6 files changed, 72 insertions(+), 45 deletions(-)
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index f3e65b9..9030743 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -16,13 +16,12 @@ import {
TimestampUnit,
} from "../utils";
+// Default maximum length for table and column names.
const DEFAULT_MAX_NAME_LENGTH = 127;
-/** @classdesc
- * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
- * If no custom agent is configured, the Sender will use its own agent which overrides some default values
- * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1.
- *
+/**
+ * Abstract base class for SenderBuffer implementations.
+ * Provides common functionality for writing data into the buffer.
*/
abstract class SenderBufferBase implements SenderBuffer {
private bufferSize: number;
@@ -40,7 +39,7 @@ abstract class SenderBufferBase implements SenderBuffer {
protected readonly log: Logger;
/**
- * Creates an instance of Sender.
+ * Creates an instance of SenderBufferBase.
*
* @param {SenderOptions} options - Sender configuration object.
* See SenderOptions documentation for detailed description of configuration options.
@@ -68,7 +67,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Extends the size of the sender's buffer.
+ * Extends the size of the buffer.
* Can be used to increase the size of buffer if overflown.
* The buffer's content is copied into the new buffer.
*
@@ -112,22 +111,20 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * @ignore
- * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
- * The returned buffer is backed by the sender's buffer.
- * Used only in tests.
+ * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
+ * The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated.
+ * Used only in tests to assert the buffer's content.
*/
toBufferView(pos = this.endOfLastRow): Buffer {
return pos > 0 ? this.buffer.subarray(0, pos) : null;
}
/**
- * @ignore
- * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
- * The returned buffer is a copy of the sender's buffer.
- * It also compacts the Sender's buffer.
+ * @return {Buffer} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
+ * The returned buffer is a copy of this buffer.
+ * It also compacts the buffer.
*/
- toBufferNew(pos = this.endOfLastRow): Buffer | null {
+ toBufferNew(pos = this.endOfLastRow): Buffer {
if (pos > 0) {
const data = Buffer.allocUnsafe(pos);
this.buffer.copy(data, 0, 0, pos);
@@ -138,7 +135,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write the table name into the buffer of the sender.
+ * Write the table name into the buffer.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -158,7 +155,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a symbol name and value into the buffer of the sender.
+ * Write a symbol name and value into the buffer.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -185,7 +182,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a string column with its value into the buffer of the sender.
+ * Write a string column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -207,7 +204,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a boolean column with its value into the buffer of the sender.
+ * Write a boolean column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -227,7 +224,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a float column with its value into the buffer of the sender.
+ * Write a float column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -236,11 +233,12 @@ abstract class SenderBufferBase implements SenderBuffer {
abstract floatColumn(name: string, value: number): SenderBuffer;
/**
- * Write an integer column with its value into the buffer of the sender.
+ * Write an integer column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
+ * @throws Error if the value is not an integer
*/
intColumn(name: string, value: number): SenderBuffer {
if (!Number.isInteger(value)) {
@@ -256,7 +254,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a timestamp column with its value into the buffer of the sender.
+ * Write a timestamp column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
@@ -282,7 +280,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Closing the row after writing the designated timestamp into the buffer of the sender.
+ * Closing the row after writing the designated timestamp into the buffer.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -308,7 +306,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Closing the row without writing designated timestamp into the buffer of the sender.
+ * Closing the row without writing designated timestamp into the buffer.
* Designated timestamp will be populated by the server on this record.
*/
atNow() {
@@ -323,7 +321,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Returns the current position of the buffer.
+ * Returns the current position of the buffer.
* New data will be written into the buffer starting from this position.
*/
currentPosition(): number {
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
index aa2a37e..2254d21 100644
--- a/src/buffer/bufferv1.ts
+++ b/src/buffer/bufferv1.ts
@@ -3,11 +3,22 @@ import { SenderOptions } from "../options";
import { SenderBuffer } from "./index";
import { SenderBufferBase } from "./base";
+/**
+ * Buffer implementation for protocol version 1.
+ * Sends floating point numbers in their text form.
+ */
class SenderBufferV1 extends SenderBufferBase {
constructor(options: SenderOptions) {
super(options);
}
+ /**
+ * Write a float column with its value into the buffer using v1 serialization (text format).
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
name,
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index e21fdb1..4b5e7ff 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -6,11 +6,22 @@ import { SenderBufferBase } from "./base";
const ENTITY_TYPE_DOUBLE: number = 16;
const EQUALS_SIGN: number = "=".charCodeAt(0);
+/**
+ * Buffer implementation for protocol version 2.
+ * Sends floating point numbers in binary form.
+ */
class SenderBufferV2 extends SenderBufferBase {
constructor(options: SenderOptions) {
super(options);
}
+ /**
+ * Write a float column with its value into the buffer using v2 serialization (binary format).
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
name,
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index da60601..b12e0ee 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -38,6 +38,7 @@ function createBuffer(options: SenderOptions): SenderBuffer {
* Buffer used by the Sender.
*/
interface SenderBuffer {
+
/**
* Resets the buffer, data added to the buffer will be lost.
* In other words it clears the buffer and sets the writing position to the beginning of the buffer.
@@ -47,23 +48,21 @@ interface SenderBuffer {
reset(): SenderBuffer;
/**
- * @ignore
- * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
- * The returned buffer is backed by the sender's buffer.
- * Used only in tests.
+ * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
+ * The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated.
+ * Used only in tests to assert the buffer's content.
*/
toBufferView(pos?: number): Buffer;
/**
- * @ignore
- * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
- * The returned buffer is a copy of the sender's buffer.
- * It also compacts the Sender's buffer.
+ * @return {Buffer} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
+ * The returned buffer is a copy of this buffer.
+ * It also compacts the buffer.
*/
toBufferNew(pos?: number): Buffer | null;
/**
- * Write the table name into the buffer of the sender.
+ * Write the table name into the buffer.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -71,7 +70,7 @@ interface SenderBuffer {
table(table: string): SenderBuffer;
/**
- * Write a symbol name and value into the buffer of the sender.
+ * Write a symbol name and value into the buffer.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -80,7 +79,7 @@ interface SenderBuffer {
symbol(name: string, value: unknown): SenderBuffer;
/**
- * Write a string column with its value into the buffer of the sender.
+ * Write a string column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -89,7 +88,7 @@ interface SenderBuffer {
stringColumn(name: string, value: string): SenderBuffer;
/**
- * Write a boolean column with its value into the buffer of the sender.
+ * Write a boolean column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -98,7 +97,7 @@ interface SenderBuffer {
booleanColumn(name: string, value: boolean): SenderBuffer;
/**
- * Write a float column with its value into the buffer of the sender.
+ * Write a float column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -107,16 +106,17 @@ interface SenderBuffer {
floatColumn(name: string, value: number): SenderBuffer;
/**
- * Write an integer column with its value into the buffer of the sender.
+ * Write an integer column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
+ * @throws Error if the value is not an integer
*/
intColumn(name: string, value: number): SenderBuffer;
/**
- * Write a timestamp column with its value into the buffer of the sender.
+ * Write a timestamp column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
@@ -130,7 +130,7 @@ interface SenderBuffer {
): SenderBuffer;
/**
- * Closing the row after writing the designated timestamp into the buffer of the sender.
+ * Closing the row after writing the designated timestamp into the buffer.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -138,13 +138,13 @@ interface SenderBuffer {
at(timestamp: number | bigint, unit: TimestampUnit): void;
/**
- * Closing the row without writing designated timestamp into the buffer of the sender.
+ * Closing the row without writing designated timestamp into the buffer.
* Designated timestamp will be populated by the server on this record.
*/
atNow(): void;
/**
- * Returns the current position of the buffer.
+ * Returns the current position of the buffer.
* New data will be written into the buffer starting from this position.
*/
currentPosition(): number;
diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts
index bd2c7c0..bc84464 100644
--- a/src/transport/tcp.ts
+++ b/src/transport/tcp.ts
@@ -175,7 +175,7 @@ class TcpTransport implements SenderTransport {
}
/**
- * Closes the TCP connection to the database.
+ * Closes the TCP connection to the database.
*/
async close(): Promise {
if (this.socket) {
diff --git a/src/utils.ts b/src/utils.ts
index 734740d..0867b2c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -36,6 +36,13 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
}
}
+/**
+ * Fetches JSON data from a URL.
+ * @template T - The expected type of the JSON response
+ * @param url - The URL to fetch from
+ * @returns Promise resolving to the parsed JSON data
+ * @throws Error if the request fails or returns a non-OK status
+ */
async function fetchJson(url: string): Promise {
let response: globalThis.Response;
try {
From 4880075afa0b74b81cec09d7942e79b7d47ad226 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 22:59:20 +0100
Subject: [PATCH 20/38] code formatting
---
src/buffer/bufferv1.ts | 14 +++++++-------
src/buffer/bufferv2.ts | 14 +++++++-------
src/buffer/index.ts | 1 -
test/util/mockhttp.ts | 7 +------
4 files changed, 15 insertions(+), 21 deletions(-)
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
index 2254d21..ff36fb3 100644
--- a/src/buffer/bufferv1.ts
+++ b/src/buffer/bufferv1.ts
@@ -12,13 +12,13 @@ class SenderBufferV1 extends SenderBufferBase {
super(options);
}
- /**
- * Write a float column with its value into the buffer using v1 serialization (text format).
- *
- * @param {string} name - Column name.
- * @param {number} value - Column value, accepts only number values.
- * @return {Sender} Returns with a reference to this sender.
- */
+ /**
+ * Write a float column with its value into the buffer using v1 serialization (text format).
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
name,
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index 4b5e7ff..3e812d8 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -15,13 +15,13 @@ class SenderBufferV2 extends SenderBufferBase {
super(options);
}
- /**
- * Write a float column with its value into the buffer using v2 serialization (binary format).
- *
- * @param {string} name - Column name.
- * @param {number} value - Column value, accepts only number values.
- * @return {Sender} Returns with a reference to this sender.
- */
+ /**
+ * Write a float column with its value into the buffer using v2 serialization (binary format).
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
+ */
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
name,
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index b12e0ee..cb72692 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -38,7 +38,6 @@ function createBuffer(options: SenderOptions): SenderBuffer {
* Buffer used by the Sender.
*/
interface SenderBuffer {
-
/**
* Resets the buffer, data added to the buffer will be lost.
* In other words it clears the buffer and sets the writing position to the beginning of the buffer.
diff --git a/test/util/mockhttp.ts b/test/util/mockhttp.ts
index c3219fa..35b72a0 100644
--- a/test/util/mockhttp.ts
+++ b/test/util/mockhttp.ts
@@ -102,13 +102,8 @@ class MockHttp {
async stop() {
if (this.server) {
- return new Promise((resolve, reject) => {
- this.server.close((err) => {
- err ? reject(err) : resolve(true);
- });
- });
+ this.server.close();
}
- return true;
}
}
From d873996feaf3e9b3f491a53de7c753c2f2b71985 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Thu, 31 Jul 2025 23:14:00 +0100
Subject: [PATCH 21/38] more js doc
---
src/options.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/options.ts b/src/options.ts
index bba203f..59c4853 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -219,6 +219,13 @@ class SenderOptions {
}
}
+ /**
+ * Resolves the protocol version, if it is set to 'auto'.
+ * If TCP transport is used, the protocol version will default to 1.
+ * In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions
+ * supported by the server, and the highest will be selected.
+ * @param options SenderOptions instance needs resolving protocol version
+ */
static async resolveAuto(options: SenderOptions) {
parseProtocolVersion(options);
if (options.protocol_version !== PROTOCOL_VERSION_AUTO) {
From 505a9bb918406fa4cd0599e924876e8f26b98e99 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Fri, 1 Aug 2025 00:52:01 +0100
Subject: [PATCH 22/38] add back scheduled run of build
---
.github/workflows/build.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3dead93..7c87581 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,6 +5,8 @@ on:
branches:
- main
pull_request:
+ schedule:
+ - cron: '15 2,10,18 * * *'
jobs:
test:
From 95d997f370aaad8f0fd7b54de9f4480f385b5fcb Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 00:46:05 +0100
Subject: [PATCH 23/38] remove redundant buffer overflow checks
---
src/buffer/base.ts | 24 +-----------------
test/sender.buffer.test.ts | 50 +++++++++++++++++++++++++++++++++++++-
2 files changed, 50 insertions(+), 24 deletions(-)
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index 9030743..906f772 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -80,11 +80,7 @@ abstract class SenderBufferBase implements SenderBuffer {
);
}
this.bufferSize = bufferSize;
- // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
- // longer than the size of the buffer. It simply just writes whatever it can, and returns.
- // If we can write into the extra byte, that indicates buffer overflow.
- // See the check in the write() function.
- const newBuffer = Buffer.alloc(this.bufferSize + 1, 0);
+ const newBuffer = Buffer.alloc(this.bufferSize, 0);
if (this.buffer) {
this.buffer.copy(newBuffer);
}
@@ -378,32 +374,14 @@ abstract class SenderBufferBase implements SenderBuffer {
protected write(data: string) {
this.position += this.buffer.write(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
}
protected writeByte(data: number) {
this.position = this.buffer.writeInt8(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
}
protected writeDouble(data: number) {
this.position = this.buffer.writeDoubleLE(data, this.position);
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(
- `Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`,
- );
- }
}
private writeEscaped(data: string, quoted = false) {
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 4f43623..f941ab8 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -732,7 +732,7 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
- it("extends the size of the buffer if data does not fit", async function () {
+ it("extends the size of the buffer v1 if data does not fit", async function () {
const sender = new Sender({
protocol: "tcp",
protocol_version: "1",
@@ -767,6 +767,39 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
+ it("extends the size of the buffer v2 if data does not fit", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 8,
+ });
+ expect(bufferSize(sender)).toBe(8);
+ expect(bufferPosition(sender)).toBe(0);
+ sender.table("tableName");
+ expect(bufferSize(sender)).toBe(24);
+ expect(bufferPosition(sender)).toBe("tableName".length);
+ sender.floatColumn("floatField", 123.456);
+ expect(bufferSize(sender)).toBe(48);
+ expect(bufferPosition(sender)).toBe("tableName floatField=".length + 10);
+ sender.stringColumn("strField", "hoho");
+ expect(bufferSize(sender)).toBe(96);
+ expect(bufferPosition(sender)).toBe(
+ "tableName floatField=".length + 10 + ',strField="hoho"'.length,
+ );
+ await sender.atNow();
+ expect(bufferSize(sender)).toBe(96);
+ expect(bufferPosition(sender)).toBe(
+ "tableName floatField=".length + 10 + ',strField="hoho"\n'.length,
+ );
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName floatField=") +
+ " 3d 10 77 be 9f 1a 2f dd 5e 40 " +
+ toHex(',strField="hoho"\n'),
+ );
+ await sender.close();
+ });
+
it("throws exception if tries to extend the size of the buffer above max buffer size", async function () {
const sender = await Sender.fromConfig(
"tcp::addr=host;init_buf_size=8;max_buf_size=64;",
@@ -838,6 +871,21 @@ function bufferContent(sender: Sender) {
return sender.buffer.toBufferView().toString();
}
+function bufferContentHex(sender: Sender) {
+ // @ts-expect-error - Accessing private field
+ return toHexString(sender.buffer.toBufferView());
+}
+
+function toHex(str: string) {
+ return toHexString(Buffer.from(str));
+}
+
+function toHexString(buffer: Buffer) {
+ return Array.from(buffer)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join(" ");
+}
+
function bufferSize(sender: Sender) {
// @ts-expect-error - Accessing private field
return sender.buffer.bufferSize;
From 4f01717b39768e3ee1be6b1332186206dcddd4cf Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 00:54:05 +0100
Subject: [PATCH 24/38] js doc fix
---
src/buffer/base.ts | 18 +++++++++---------
src/buffer/bufferv1.ts | 2 +-
src/buffer/bufferv2.ts | 2 +-
3 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index 906f772..3367437 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -131,7 +131,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write the table name into the buffer.
+ * Writes the table name into the buffer.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -151,7 +151,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a symbol name and value into the buffer.
+ * Writes a symbol name and value into the buffer.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -178,7 +178,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a string column with its value into the buffer.
+ * Writes a string column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -200,7 +200,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a boolean column with its value into the buffer.
+ * Writes a boolean column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -220,7 +220,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a float column with its value into the buffer.
+ * Writes a float column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -229,7 +229,7 @@ abstract class SenderBufferBase implements SenderBuffer {
abstract floatColumn(name: string, value: number): SenderBuffer;
/**
- * Write an integer column with its value into the buffer.
+ * Writes an integer column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -250,7 +250,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Write a timestamp column with its value into the buffer.
+ * Writes a timestamp column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
@@ -276,7 +276,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Closing the row after writing the designated timestamp into the buffer.
+ * Closes the row after writing the designated timestamp into the buffer.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -302,7 +302,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Closing the row without writing designated timestamp into the buffer.
+ * Closes the row without writing designated timestamp into the buffer.
* Designated timestamp will be populated by the server on this record.
*/
atNow() {
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
index ff36fb3..181a47d 100644
--- a/src/buffer/bufferv1.ts
+++ b/src/buffer/bufferv1.ts
@@ -13,7 +13,7 @@ class SenderBufferV1 extends SenderBufferBase {
}
/**
- * Write a float column with its value into the buffer using v1 serialization (text format).
+ * Writes a float column with its value into the buffer using v1 serialization (text format).
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index 3e812d8..a93838c 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -16,7 +16,7 @@ class SenderBufferV2 extends SenderBufferBase {
}
/**
- * Write a float column with its value into the buffer using v2 serialization (binary format).
+ * Writes a float column with its value into the buffer using v2 serialization (binary format).
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
From ccb6cabb449bc7fd2bb5e441f69d707bb4b85333 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 01:00:31 +0100
Subject: [PATCH 25/38] js doc fix
---
src/buffer/index.ts | 18 +++++++++---------
src/sender.ts | 18 +++++++++---------
2 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index cb72692..919871c 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -61,7 +61,7 @@ interface SenderBuffer {
toBufferNew(pos?: number): Buffer | null;
/**
- * Write the table name into the buffer.
+ * Writes the table name into the buffer.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -69,7 +69,7 @@ interface SenderBuffer {
table(table: string): SenderBuffer;
/**
- * Write a symbol name and value into the buffer.
+ * Writes a symbol name and value into the buffer.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -78,7 +78,7 @@ interface SenderBuffer {
symbol(name: string, value: unknown): SenderBuffer;
/**
- * Write a string column with its value into the buffer.
+ * Writes a string column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -87,7 +87,7 @@ interface SenderBuffer {
stringColumn(name: string, value: string): SenderBuffer;
/**
- * Write a boolean column with its value into the buffer.
+ * Writes a boolean column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -96,7 +96,7 @@ interface SenderBuffer {
booleanColumn(name: string, value: boolean): SenderBuffer;
/**
- * Write a float column with its value into the buffer.
+ * Writes a float column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -105,7 +105,7 @@ interface SenderBuffer {
floatColumn(name: string, value: number): SenderBuffer;
/**
- * Write an integer column with its value into the buffer.
+ * Writes an integer column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -115,7 +115,7 @@ interface SenderBuffer {
intColumn(name: string, value: number): SenderBuffer;
/**
- * Write a timestamp column with its value into the buffer.
+ * Writes a timestamp column with its value into the buffer.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
@@ -129,7 +129,7 @@ interface SenderBuffer {
): SenderBuffer;
/**
- * Closing the row after writing the designated timestamp into the buffer.
+ * Closes the row after writing the designated timestamp into the buffer.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -137,7 +137,7 @@ interface SenderBuffer {
at(timestamp: number | bigint, unit: TimestampUnit): void;
/**
- * Closing the row without writing designated timestamp into the buffer.
+ * Closes the row without writing designated timestamp into the buffer.
* Designated timestamp will be populated by the server on this record.
*/
atNow(): void;
diff --git a/src/sender.ts b/src/sender.ts
index bd41d89..1925682 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -183,7 +183,7 @@ class Sender {
}
/**
- * Write the table name into the buffer of the sender.
+ * Writes the table name into the buffer of the sender.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -194,7 +194,7 @@ class Sender {
}
/**
- * Write a symbol name and value into the buffer of the sender.
+ * Writes a symbol name and value into the buffer of the sender.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -206,7 +206,7 @@ class Sender {
}
/**
- * Write a string column with its value into the buffer of the sender.
+ * Writes a string column with its value into the buffer of the sender.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -218,7 +218,7 @@ class Sender {
}
/**
- * Write a boolean column with its value into the buffer of the sender.
+ * Writes a boolean column with its value into the buffer of the sender.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -230,7 +230,7 @@ class Sender {
}
/**
- * Write a float column with its value into the buffer of the sender.
+ * Writes a float column with its value into the buffer of the sender.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -242,7 +242,7 @@ class Sender {
}
/**
- * Write an integer column with its value into the buffer of the sender.
+ * Writes an integer column with its value into the buffer of the sender.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -254,7 +254,7 @@ class Sender {
}
/**
- * Write a timestamp column with its value into the buffer of the sender.
+ * Writes a timestamp column with its value into the buffer of the sender.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
@@ -271,7 +271,7 @@ class Sender {
}
/**
- * Closing the row after writing the designated timestamp into the buffer of the sender.
+ * Closes the row after writing the designated timestamp into the buffer of the sender.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -284,7 +284,7 @@ class Sender {
}
/**
- * Closing the row without writing designated timestamp into the buffer of the sender.
+ * Closes the row without writing designated timestamp into the buffer of the sender.
* Designated timestamp will be populated by the server on this record.
*/
async atNow() {
From 812d5b8c7aa3b3062a6fe11359cb87b0f43098ed Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 02:38:20 +0100
Subject: [PATCH 26/38] use request timeout and TLS settings from Sender
options in '/settings' fetch()
---
src/options.ts | 19 +++++++++++++++++--
src/transport/http/base.ts | 7 ++++++-
src/utils.ts | 19 +++++++++++++++++--
test/options.test.ts | 12 ++++++------
4 files changed, 46 insertions(+), 11 deletions(-)
diff --git a/src/options.ts b/src/options.ts
index 59c4853..7523270 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -1,10 +1,12 @@
+import { readFileSync } from "node:fs";
import { PathOrFileDescriptor } from "fs";
import { Agent } from "undici";
import http from "http";
import https from "https";
import { Logger } from "./logging";
-import { fetchJson } from "./utils";
+import { fetchJson, isBoolean, isInteger } from "./utils";
+import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base";
const HTTP_PORT = 9000;
const TCP_PORT = 9009;
@@ -235,7 +237,20 @@ class SenderOptions {
const url = `${options.protocol}://${options.host}:${options.port}/settings`;
const settings: {
config: { LINE_PROTO_SUPPORT_VERSION: number[] };
- } = await fetchJson(url);
+ } = await fetchJson(
+ url,
+ isInteger(options.request_timeout, 1)
+ ? options.request_timeout
+ : DEFAULT_REQUEST_TIMEOUT,
+ new Agent({
+ connect: {
+ ca: options.tls_ca ? readFileSync(options.tls_ca) : undefined,
+ rejectUnauthorized: isBoolean(options.tls_verify)
+ ? options.tls_verify
+ : true,
+ },
+ }),
+ );
const supportedVersions: string[] = (
settings.config[LINE_PROTO_SUPPORT_VERSION] ?? []
).map((version: unknown) => String(version));
diff --git a/src/transport/http/base.ts b/src/transport/http/base.ts
index 5c47532..f4f8dd7 100644
--- a/src/transport/http/base.ts
+++ b/src/transport/http/base.ts
@@ -135,4 +135,9 @@ abstract class HttpTransportBase implements SenderTransport {
abstract send(data: Buffer): Promise;
}
-export { HttpTransportBase, RETRIABLE_STATUS_CODES, HTTP_NO_CONTENT };
+export {
+ HttpTransportBase,
+ RETRIABLE_STATUS_CODES,
+ HTTP_NO_CONTENT,
+ DEFAULT_REQUEST_TIMEOUT,
+};
diff --git a/src/utils.ts b/src/utils.ts
index 0867b2c..1cd7390 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,5 @@
+import { Agent } from "undici";
+
type TimestampUnit = "ns" | "us" | "ms";
function isBoolean(value: unknown): value is boolean {
@@ -40,13 +42,26 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
* Fetches JSON data from a URL.
* @template T - The expected type of the JSON response
* @param url - The URL to fetch from
+ * @param agent - HTTP agent to be used for the request
+ * @param timeout - Request timeout, query will be aborted if not finished in time
* @returns Promise resolving to the parsed JSON data
* @throws Error if the request fails or returns a non-OK status
*/
-async function fetchJson(url: string): Promise {
+async function fetchJson(
+ url: string,
+ timeout: number,
+ agent: Agent,
+): Promise {
+ const controller = new AbortController();
+ const { signal } = controller;
+ setTimeout(() => controller.abort(), timeout);
+
let response: globalThis.Response;
try {
- response = await fetch(url);
+ response = await fetch(url, {
+ dispatcher: agent,
+ signal,
+ });
} catch (error) {
throw new Error(`Failed to load ${url} [error=${error}]`);
}
diff --git a/test/options.test.ts b/test/options.test.ts
index 19ce449..1f9dd01 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -281,7 +281,7 @@ describe("Configuration string parser suite", function () {
);
expect(options.protocol_version).toBe("2");
options = await SenderOptions.fromConfig(
- `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
);
expect(options.protocol_version).toBe("2");
@@ -302,7 +302,7 @@ describe("Configuration string parser suite", function () {
);
expect(options.protocol_version).toBe("1");
options = await SenderOptions.fromConfig(
- `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
);
expect(options.protocol_version).toBe("1");
@@ -323,7 +323,7 @@ describe("Configuration string parser suite", function () {
);
expect(options.protocol_version).toBe("1");
options = await SenderOptions.fromConfig(
- `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
);
expect(options.protocol_version).toBe("1");
@@ -342,7 +342,7 @@ describe("Configuration string parser suite", function () {
await expect(
async () =>
await SenderOptions.fromConfig(
- `http::addr=localhost:${MOCK_HTTP_PORT}`,
+ `http::addr=localhost:${MOCK_HTTP_PORT};tls_verify=unsafe_off`,
),
).rejects.toThrow(
"Unsupported protocol versions received from server: 3,5",
@@ -350,7 +350,7 @@ describe("Configuration string parser suite", function () {
await expect(
async () =>
await SenderOptions.fromConfig(
- `https::addr=localhost:${MOCK_HTTPS_PORT}`,
+ `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`,
),
).rejects.toThrow(
"Unsupported protocol versions received from server: 3,5",
@@ -407,7 +407,7 @@ describe("Configuration string parser suite", function () {
);
expect(options.protocol_version).toBe("2");
options = await SenderOptions.fromConfig(
- `https::addr=localhost:${MOCK_HTTPS_PORT};protocol_version=auto`,
+ `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off;protocol_version=auto`,
);
expect(options.protocol_version).toBe("2");
});
From 7c79739426bc729874d6f6037d1def6f136a24e7 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 02:48:32 +0100
Subject: [PATCH 27/38] use bracket notation for LINE_PROTO_SUPPORT_VERSION
---
src/options.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/options.ts b/src/options.ts
index 7523270..f1b588c 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -236,7 +236,7 @@ class SenderOptions {
const url = `${options.protocol}://${options.host}:${options.port}/settings`;
const settings: {
- config: { LINE_PROTO_SUPPORT_VERSION: number[] };
+ config: { [LINE_PROTO_SUPPORT_VERSION]: number[] };
} = await fetchJson(
url,
isInteger(options.request_timeout, 1)
From adf94c3a439063ffded3b7e3b8fc7f7d36724ea2 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 03:06:39 +0100
Subject: [PATCH 28/38] move writeArray() into buffer v2 from base
---
src/buffer/base.ts | 61 ++----------------------------------------
src/buffer/bufferv2.ts | 58 ++++++++++++++++++++++++++++++++++++++-
2 files changed, 59 insertions(+), 60 deletions(-)
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index 591a8d9..1a4ef09 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -10,7 +10,6 @@ import {
DEFAULT_MAX_BUFFER_SIZE,
} from "./index";
import {
- ArrayPrimitive,
isInteger,
timestampToMicros,
timestampToNanos,
@@ -27,8 +26,8 @@ const DEFAULT_MAX_NAME_LENGTH = 127;
abstract class SenderBufferBase implements SenderBuffer {
private bufferSize: number;
private readonly maxBufferSize: number;
- private buffer: Buffer;
- private position: number;
+ protected buffer: Buffer;
+ protected position: number;
private endOfLastRow: number;
private hasTable: boolean;
@@ -398,43 +397,6 @@ abstract class SenderBufferBase implements SenderBuffer {
this.position = this.buffer.writeDoubleLE(data, this.position);
}
- protected writeArray(
- arr: unknown[],
- dimensions: number[],
- type: ArrayPrimitive,
- ) {
- this.checkCapacity([], 1 + dimensions.length * 4);
- this.writeByte(dimensions.length);
- for (let i = 0; i < dimensions.length; i++) {
- this.writeInt(dimensions[i]);
- }
-
- this.checkCapacity([], SenderBufferBase.arraySize(dimensions, type));
- this.writeArrayValues(arr, dimensions);
- }
-
- private writeArrayValues(arr: unknown[], dimensions: number[]) {
- if (Array.isArray(arr[0])) {
- for (let i = 0; i < arr.length; i++) {
- this.writeArrayValues(arr[i] as unknown[], dimensions);
- }
- } else {
- const type = typeof arr[0];
- switch (type) {
- case "number":
- for (let i = 0; i < arr.length; i++) {
- this.position = this.buffer.writeDoubleLE(
- arr[i] as number,
- this.position,
- );
- }
- break;
- default:
- throw new Error(`Unsupported array type [type=${type}]`);
- }
- }
- }
-
private writeEscaped(data: string, quoted = false) {
for (const ch of data) {
if (ch > "\\") {
@@ -471,25 +433,6 @@ abstract class SenderBufferBase implements SenderBuffer {
}
}
}
-
- private static arraySize(dimensions: number[], type: ArrayPrimitive): number {
- let numOfElements = 1;
- for (let i = 0; i < dimensions.length; i++) {
- numOfElements *= dimensions[i];
- }
-
- switch (type) {
- case "number":
- return numOfElements * 8;
- case "boolean":
- return numOfElements;
- case "string":
- // in case of string[] capacity check is done separately for each array element
- return 0;
- default:
- throw new Error(`Unsupported array type [type=${type}]`);
- }
- }
}
export { SenderBufferBase };
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index 8715b90..65111e7 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -2,7 +2,7 @@
import { SenderOptions } from "../options";
import { SenderBuffer } from "./index";
import { SenderBufferBase } from "./base";
-import { getDimensions, validateArray } from "../utils";
+import { ArrayPrimitive, getDimensions, validateArray } from "../utils";
const COLUMN_TYPE_DOUBLE: number = 10;
const COLUMN_TYPE_NULL: number = 33;
@@ -65,6 +65,62 @@ class SenderBufferV2 extends SenderBufferBase {
});
return this;
}
+
+ private writeArray(
+ arr: unknown[],
+ dimensions: number[],
+ type: ArrayPrimitive,
+ ) {
+ this.checkCapacity([], 1 + dimensions.length * 4);
+ this.writeByte(dimensions.length);
+ for (let i = 0; i < dimensions.length; i++) {
+ this.writeInt(dimensions[i]);
+ }
+
+ this.checkCapacity([], SenderBufferV2.arraySize(dimensions, type));
+ this.writeArrayValues(arr, dimensions);
+ }
+
+ private writeArrayValues(arr: unknown[], dimensions: number[]) {
+ if (Array.isArray(arr[0])) {
+ for (let i = 0; i < arr.length; i++) {
+ this.writeArrayValues(arr[i] as unknown[], dimensions);
+ }
+ } else {
+ const type = typeof arr[0];
+ switch (type) {
+ case "number":
+ for (let i = 0; i < arr.length; i++) {
+ this.position = this.buffer.writeDoubleLE(
+ arr[i] as number,
+ this.position,
+ );
+ }
+ break;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
+ }
+
+ private static arraySize(dimensions: number[], type: ArrayPrimitive): number {
+ let numOfElements = 1;
+ for (let i = 0; i < dimensions.length; i++) {
+ numOfElements *= dimensions[i];
+ }
+
+ switch (type) {
+ case "number":
+ return numOfElements * 8;
+ case "boolean":
+ return numOfElements;
+ case "string":
+ // in case of string[] capacity check is done separately for each array element
+ return 0;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
}
export { SenderBufferV2 };
From d70554642a56bad97768a28e635eb880e1392107 Mon Sep 17 00:00:00 2001
From: glasstiger <94906625+glasstiger@users.noreply.github.com>
Date: Wed, 6 Aug 2025 03:07:36 +0100
Subject: [PATCH 29/38] Update src/utils.ts
Co-authored-by: Andrei Pechkurov <37772591+puzpuzpuz@users.noreply.github.com>
---
src/utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/utils.ts b/src/utils.ts
index 8d898b4..d6df200 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -72,7 +72,7 @@ function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
const expectedLength = dimensions[depth];
if (array.length !== expectedLength) {
throw new Error(
- `Length of arrays do not match [expected=${expectedLength}, actual=${array.length}, dimensions=[${dimensions}], path=${path}]`,
+ `Lengths of sub-arrays do not match [expected=${expectedLength}, actual=${array.length}, dimensions=[${dimensions}], path=${path}]`,
);
}
From 8ad622f5b71a4e8754943bf8de91740bfed8b4d9 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 03:09:12 +0100
Subject: [PATCH 30/38] fix test
---
test/sender.buffer.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 34850de..3541f9d 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -262,7 +262,7 @@ describe("Sender message builder test suite (anything not covered in client inte
[[1.1, 2.2], [3.3], [5.5, 6.6]],
]),
).toThrow(
- "Length of arrays do not match [expected=2, actual=1, dimensions=[4,3,2], path=[3][1]]",
+ "Lengths of sub-arrays do not match [expected=2, actual=1, dimensions=[4,3,2], path=[3][1]]",
);
await sender.close();
});
From c4feb7b4283290d3680991da88e4db8c48b0ae30 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 04:47:17 +0100
Subject: [PATCH 31/38] empty array support
---
src/buffer/bufferv2.ts | 8 +-
src/utils.ts | 9 +-
test/sender.buffer.test.ts | 32 ++++-
test/sender.integration.test.ts | 211 ++++++++++++++++++++++++++++++++
4 files changed, 248 insertions(+), 12 deletions(-)
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index 65111e7..20879fb 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -87,7 +87,7 @@ class SenderBufferV2 extends SenderBufferBase {
this.writeArrayValues(arr[i] as unknown[], dimensions);
}
} else {
- const type = typeof arr[0];
+ const type = arr[0] ? typeof arr[0] : null;
switch (type) {
case "number":
for (let i = 0; i < arr.length; i++) {
@@ -97,6 +97,9 @@ class SenderBufferV2 extends SenderBufferBase {
);
}
break;
+ case null:
+ // empty array
+ break;
default:
throw new Error(`Unsupported array type [type=${type}]`);
}
@@ -117,6 +120,9 @@ class SenderBufferV2 extends SenderBufferBase {
case "string":
// in case of string[] capacity check is done separately for each array element
return 0;
+ case null:
+ // empty array
+ return 0;
default:
throw new Error(`Unsupported array type [type=${type}]`);
}
diff --git a/src/utils.ts b/src/utils.ts
index d6df200..adbd7df 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -43,9 +43,6 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
function getDimensions(data: unknown) {
const dimensions: number[] = [];
while (Array.isArray(data)) {
- if (data.length === 0) {
- throw new Error("Zero length array not supported");
- }
dimensions.push(data.length);
data = data[0];
}
@@ -88,7 +85,7 @@ function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
}
} else {
// leaf level, expecting primitives
- if (expectedType === null) {
+ if (expectedType === null && array[0]) {
expectedType = typeof array[0] as ArrayPrimitive;
}
@@ -96,7 +93,9 @@ function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
const currentType = typeof array[i] as ArrayPrimitive;
if (currentType !== expectedType) {
throw new Error(
- `Mixed types found [expected=${expectedType}, current=${currentType}, path=${path}[${i}]]`,
+ expectedType !== null
+ ? `Mixed types found [expected=${expectedType}, current=${currentType}, path=${path}[${i}]]`
+ : `Unsupported array type [type=${currentType}]`,
);
}
}
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 3541f9d..340f352 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -218,19 +218,39 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
- it("does not accept empty array", async function () {
+ it("accepts empty array", async function () {
const sender = new Sender({
protocol: "tcp",
protocol_version: "2",
host: "host",
init_buf_size: 1024,
});
- sender.table("tableName");
- expect(() => sender.arrayColumn("arrayCol", [])).toThrow(
- "Zero length array not supported",
+ await sender.table("tableName").arrayColumn("arrayCol", []).atNow();
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName arrayCol==") + " 0e 0a 01 00 00 00 00 " + toHex("\n"),
);
- expect(() => sender.arrayColumn("arrayCol", [[], []])).toThrow(
- "Zero length array not supported",
+ await sender.close();
+ });
+
+ it("accepts multi dimensional empty array", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender
+ .table("tableName")
+ .arrayColumn("arrayCol", [
+ [[], []],
+ [[], []],
+ [[], []],
+ ])
+ .atNow();
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName arrayCol==") +
+ " 0e 0a 03 03 00 00 00 02 00 00 00 00 00 00 00 " +
+ toHex("\n"),
);
await sender.close();
});
diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts
index fd5a5c7..228d015 100644
--- a/test/sender.integration.test.ts
+++ b/test/sender.integration.test.ts
@@ -246,6 +246,217 @@ describe("Sender tests with containerized QuestDB instance", () => {
await sender.close();
});
+ it("can ingest data via HTTP with protocol v2", async () => {
+ const tableName = "test_http_v2";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperatures", type: "ARRAY", elemType: "DOUBLE", dim: 1 },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = await Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)};auto_flush_rows=1`,
+ );
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .arrayColumn("temperatures", [17.1, 17.7, 18.4])
+ .at(1658484765000000000n, "ns");
+
+ // wait for the table
+ await waitForTable(container, tableName);
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 1);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(1);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ ["us", [17.1, 17.7, 18.4], "2022-07-22T10:12:45.000000Z"],
+ ]);
+
+ // ingest via client, add new columns
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .arrayColumn("temperatures", [17.36, 18.4, 19.6, 18.7])
+ .at(1658484765000666000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "emea")
+ .symbol("city", "london")
+ .arrayColumn("temperatures", [18.5, 18.4, 19.2])
+ .floatColumn("daily_avg_temp", 18.7)
+ .at(1658484765001234000n, "ns");
+
+ // query table
+ const select2Result = await runSelect(container, tableName, 3);
+ expect(select2Result.query).toBe(tableName);
+ expect(select2Result.count).toBe(3);
+ expect(select2Result.columns).toStrictEqual([
+ { name: "location", type: "SYMBOL" },
+ { name: "temperatures", type: "ARRAY", elemType: "DOUBLE", dim: 1 },
+ { name: "timestamp", type: "TIMESTAMP" },
+ { name: "city", type: "SYMBOL" },
+ { name: "daily_avg_temp", type: "DOUBLE" },
+ ]);
+ expect(select2Result.dataset).toStrictEqual([
+ ["us", [17.1, 17.7, 18.4], "2022-07-22T10:12:45.000000Z", null, null],
+ [
+ "us",
+ [17.36, 18.4, 19.6, 18.7],
+ "2022-07-22T10:12:45.000666Z",
+ null,
+ null,
+ ],
+ [
+ "emea",
+ [18.5, 18.4, 19.2],
+ "2022-07-22T10:12:45.001234Z",
+ "london",
+ 18.7,
+ ],
+ ]);
+
+ await sender.close();
+ });
+
+ it("can ingest NULL array via HTTP with protocol v2", async () => {
+ const tableName = "test_http_v2_null";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperatures", type: "ARRAY", elemType: "DOUBLE", dim: 1 },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = await Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`,
+ );
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .arrayColumn("temperatures", [17.1, 17.7, 18.4])
+ .at(1658484765000000000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "gb")
+ .at(1658484765000666000n, "ns");
+ await sender.flush();
+
+ // wait for the table
+ await waitForTable(container, tableName);
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 2);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(2);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ ["us", [17.1, 17.7, 18.4], "2022-07-22T10:12:45.000000Z"],
+ ["gb", null, "2022-07-22T10:12:45.000666Z"],
+ ]);
+
+ await sender.close();
+ });
+
+ it("can ingest empty array via HTTP with protocol v2", async () => {
+ const tableName = "test_http_v2_empty";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperatures", type: "ARRAY", elemType: "DOUBLE", dim: 1 },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = await Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`,
+ );
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .arrayColumn("temperatures", [17.1, 17.7, 18.4])
+ .at(1658484765000000000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "gb")
+ .arrayColumn("temperatures", [])
+ .at(1658484765000666000n, "ns");
+ await sender.flush();
+
+ // wait for the table
+ await waitForTable(container, tableName);
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 2);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(2);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ ["us", [17.1, 17.7, 18.4], "2022-07-22T10:12:45.000000Z"],
+ ["gb", [], "2022-07-22T10:12:45.000666Z"],
+ ]);
+
+ await sender.close();
+ });
+
+ it("can ingest multi dimensional empty array via HTTP with protocol v2", async () => {
+ const tableName = "test_http_v2_multi_empty";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperatures", type: "ARRAY", elemType: "DOUBLE", dim: 2 },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = await Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`,
+ );
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .arrayColumn("temperatures", [
+ [17.1, 17.7],
+ [18.4, 18.7],
+ ])
+ .at(1658484765000000000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "gb")
+ .arrayColumn("temperatures", [[], []])
+ .at(1658484765000666000n, "ns");
+ await sender.flush();
+
+ // wait for the table
+ await waitForTable(container, tableName);
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 2);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(2);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ [
+ "us",
+ [
+ [17.1, 17.7],
+ [18.4, 18.7],
+ ],
+ "2022-07-22T10:12:45.000000Z",
+ ],
+ // todo: this should be [[], []]
+ // probably a server bug
+ ["gb", [], "2022-07-22T10:12:45.000666Z"],
+ ]);
+
+ await sender.close();
+ }, 60000000);
+
it("can ingest data via HTTP with auto flush interval", async () => {
const tableName = "test_http_interval";
const schema = [
From 3acd71f52e7cd68c9704db65240358b005647333 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 16:47:40 +0100
Subject: [PATCH 32/38] handle zeros in arrays properly
---
src/buffer/bufferv2.ts | 2 +-
src/utils.ts | 2 +-
test/sender.buffer.test.ts | 16 +++++++++
test/sender.integration.test.ts | 61 +++++++++++++++++++++++++++++++++
4 files changed, 79 insertions(+), 2 deletions(-)
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index 9b746df..2ad167e 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -88,7 +88,7 @@ class SenderBufferV2 extends SenderBufferBase {
this.writeArrayValues(arr[i] as unknown[], dimensions);
}
} else {
- const type = arr[0] ? typeof arr[0] : null;
+ const type = arr[0] !== undefined ? typeof arr[0] : null;
switch (type) {
case "number":
for (let i = 0; i < arr.length; i++) {
diff --git a/src/utils.ts b/src/utils.ts
index 2f2e054..4141f20 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -85,7 +85,7 @@ function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
}
} else {
// leaf level, expecting primitives
- if (expectedType === null && array[0]) {
+ if (expectedType === null && array[0] !== undefined) {
expectedType = typeof array[0] as ArrayPrimitive;
}
diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts
index 340f352..f9f5119 100644
--- a/test/sender.buffer.test.ts
+++ b/test/sender.buffer.test.ts
@@ -199,6 +199,22 @@ describe("Sender message builder test suite (anything not covered in client inte
await sender.close();
});
+ it("supports arrays with zeros", async function () {
+ const sender = new Sender({
+ protocol: "tcp",
+ protocol_version: "2",
+ host: "host",
+ init_buf_size: 1024,
+ });
+ await sender.table("tableName").arrayColumn("arrayCol", [0.0, 0.0]).atNow();
+ expect(bufferContentHex(sender)).toBe(
+ toHex("tableName arrayCol==") +
+ " 0e 0a 01 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " +
+ toHex("\n"),
+ );
+ await sender.close();
+ });
+
it("supports multidimensional arrays with protocol v2", async function () {
const sender = new Sender({
protocol: "tcp",
diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts
index 228d015..397c6c3 100644
--- a/test/sender.integration.test.ts
+++ b/test/sender.integration.test.ts
@@ -363,6 +363,67 @@ describe("Sender tests with containerized QuestDB instance", () => {
await sender.close();
});
+ it("can ingest zero vector via HTTP with protocol v2", async () => {
+ const tableName = "test_http_v2_zeros";
+ const schema = [
+ { name: "location", type: "SYMBOL" },
+ { name: "temperatures", type: "ARRAY", elemType: "DOUBLE", dim: 2 },
+ { name: "timestamp", type: "TIMESTAMP" },
+ ];
+
+ const sender = await Sender.fromConfig(
+ `http::addr=${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`,
+ );
+
+ // ingest via client
+ await sender
+ .table(tableName)
+ .symbol("location", "us")
+ .arrayColumn("temperatures", [
+ [17.1, 17.7, 18.4],
+ [17.1, 17.7, 18.4],
+ ])
+ .at(1658484765000000000n, "ns");
+ await sender
+ .table(tableName)
+ .symbol("location", "gb")
+ .arrayColumn("temperatures", [
+ [0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0],
+ ])
+ .at(1658484765000666000n, "ns");
+ await sender.flush();
+
+ // wait for the table
+ await waitForTable(container, tableName);
+
+ // query table
+ const select1Result = await runSelect(container, tableName, 2);
+ expect(select1Result.query).toBe(tableName);
+ expect(select1Result.count).toBe(2);
+ expect(select1Result.columns).toStrictEqual(schema);
+ expect(select1Result.dataset).toStrictEqual([
+ [
+ "us",
+ [
+ [17.1, 17.7, 18.4],
+ [17.1, 17.7, 18.4],
+ ],
+ "2022-07-22T10:12:45.000000Z",
+ ],
+ [
+ "gb",
+ [
+ [0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0],
+ ],
+ "2022-07-22T10:12:45.000666Z",
+ ],
+ ]);
+
+ await sender.close();
+ });
+
it("can ingest empty array via HTTP with protocol v2", async () => {
const tableName = "test_http_v2_empty";
const schema = [
From 77926feef6ac7ee126782fab326d45b99f74f05b Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 18:33:15 +0100
Subject: [PATCH 33/38] js doc update
---
docs/HttpTransport.html | 375 +------------
docs/HttpTransportBase.html | 31 +-
docs/Sender.html | 138 ++---
docs/SenderBufferBase.html | 998 +++++------------------------------
docs/SenderBufferV1.html | 55 +-
docs/SenderBufferV2.html | 86 ++-
docs/SenderOptions.html | 169 +++++-
docs/TcpTransport.html | 189 +------
docs/UndiciTransport.html | 116 +---
docs/global.html | 308 ++++++++---
docs/index.html | 4 +-
docs/index.js.html | 697 +++++++++++++-----------
src/buffer/base.ts | 52 +-
src/buffer/bufferv1.ts | 6 +-
src/buffer/bufferv2.ts | 13 +-
src/buffer/index.ts | 39 +-
src/logging.ts | 9 +-
src/options.ts | 14 +-
src/sender.ts | 60 ++-
src/transport/http/base.ts | 7 +-
src/transport/http/stdlib.ts | 24 +-
src/transport/http/undici.ts | 4 +-
src/transport/index.ts | 6 +-
src/transport/tcp.ts | 29 +-
src/utils.ts | 28 +-
src/validation.ts | 8 +-
26 files changed, 1349 insertions(+), 2116 deletions(-)
diff --git a/docs/HttpTransport.html b/docs/HttpTransport.html
index 0533fcd..51bc159 100644
--- a/docs/HttpTransport.html
+++ b/docs/HttpTransport.html
@@ -30,8 +30,7 @@
Class: HttpTransport
HttpTransport(options)
-
Legacy HTTP transport implementation using Node.js built-in http/https modules.
-Provides HTTP requests with manual retry logic and connection pooling.
+
HTTP transport implementation using Node.js built-in http/https modules.
Supports both HTTP and HTTPS protocols with configurable authentication.
@@ -55,7 +54,7 @@
new Http
- Creates a new HttpTransport instance using legacy Node.js HTTP modules.
+ Creates a new HttpTransport instance using Node.js HTTP modules.
-The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
-serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
+The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
+serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS
by setting the protocol_version to 'auto' (default behavior).
@@ -179,7 +179,7 @@
Parameters:
Sender configuration object.
-See SenderOptions documentation for detailed description of configuration options.
+See SenderOptions documentation for detailed description of configuration options.
@@ -220,7 +220,7 @@
- Write an array column with its values into the buffer of the sender.
-Note: Array columns are only supported in protocol version 2. If using protocol version 1,
-this method will throw an error.
+ Writes an array column with its values into the buffer of the sender.
@@ -338,7 +336,7 @@
Parameters:
-
Column name.
+
Column name
@@ -361,7 +359,7 @@
Parameters:
-
Array values to be written. Currently supports arrays of numbers.
+
Array values to write (currently supports double arrays)
- If protocol version 1 is used, as arrays are not supported.
-
-
-
-
-
-
- Type
-
-
-
-Error
-
-
-
-
-
-
-
+
+
+ Error if arrays are not supported by the buffer implementation, or array validation fails:
+- value is not an array
+- or the shape of the array is irregular: the length of sub-arrays are different
+- or the array is not homogeneous: its elements are not all the same type
+
+
@@ -497,7 +481,7 @@
(async) at
- Closing the row after writing the designated timestamp into the buffer of the sender.
+ Closes the row after writing the designated timestamp into the buffer of the sender.
@@ -648,7 +632,7 @@
(async) atNow
- Closing the row without writing designated timestamp into the buffer of the sender.
+ Closes the row without writing designated timestamp into the buffer of the sender.
Designated timestamp will be populated by the server on this record.
@@ -737,7 +721,7 @@
- Write a boolean column with its value into the buffer of the sender.
+ Writes a boolean column with its value into the buffer of the sender.
+Use it to insert into BOOLEAN columns.
(async) close
- Closes the TCP connection to the database.
+ Closes the connection to the database.
Data sitting in the Sender's buffer will be lost unless flush() is called before close().
@@ -1008,7 +993,7 @@
- Write a float column with its value into the buffer of the sender.
+ Writes a 64-bit floating point value into the buffer of the sender.
+Use it to insert into DOUBLE or FLOAT database columns.
(async) flush
- Sends the buffer's content to the database and compacts the buffer.
+ Sends the content of the sender's buffer to the database and compacts the buffer.
If the last row is not finished it stays in the sender's buffer.
@@ -1389,7 +1375,7 @@
intColumn
- Write an integer column with its value into the buffer of the sender.
+ Writes a 64-bit signed integer into the buffer of the sender.
+Use it to insert into LONG, INT, SHORT and BYTE columns.
@@ -1571,7 +1558,7 @@
reset
- Resets the buffer, data added to the buffer will be lost.
-In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ Resets the sender's buffer, data sitting in the buffer will be lost.
+In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
@@ -1682,7 +1681,7 @@
- Write a string column with its value into the buffer of the sender.
+ Writes a string column with its value into the buffer of the sender.
+Use it to insert into VARCHAR and STRING columns.
symbol
- Write a symbol name and value into the buffer of the sender.
+ Writes a symbol name and value into the buffer of the sender.
+Use it to insert into SYMBOL columns.
@@ -2046,7 +2047,7 @@
- Write a timestamp column with its value into the buffer of the sender.
+ Writes a timestamp column with its value into the buffer of the sender.
+Use it to insert into TIMESTAMP columns.
- Creates a Sender options object by parsing the provided configuration string.
+ Creates a Sender object by parsing the provided configuration string.
@@ -2601,8 +2603,8 @@
Parameters:
Optional extra configuration.
- 'log' is a logging function used by the Sender.
Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
-- 'agent' is a custom Undici agent used by the Sender when http/https transport is used.
-A undici.Agent object is expected.
+- 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
+Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
@@ -2643,7 +2645,7 @@
- Creates a Sender options object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.
+ Creates a Sender object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.
@@ -2764,8 +2766,8 @@
Parameters:
Optional extra configuration.
- 'log' is a logging function used by the Sender.
Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
-- 'agent' is a custom Undici agent used by the Sender when http/https transport is used.
-A undici.Agent object is expected.
+- 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
+Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
@@ -2806,7 +2808,7 @@
Abstract base class for QuestDB line protocol buffer implementations.
-Provides common functionality for building line protocol messages including
-table names, symbols, columns, and timestamps.
+
Abstract base class for SenderBuffer implementations.
+Provides common functionality for writing data into the buffer.
at
- Closing the row after writing the designated timestamp into the buffer of the sender.
+ Closes the row after writing the designated timestamp into the buffer.
@@ -358,7 +363,7 @@
atNow
- Closing the row without writing designated timestamp into the buffer of the sender.
+ Closes the row without writing designated timestamp into the buffer.
Designated timestamp will be populated by the server on this record.
@@ -447,7 +452,7 @@
- Write a boolean column with its value into the buffer of the sender.
+ Writes a boolean column with its value into the buffer.
+Use it to insert into BOOLEAN columns.
compact
- Compacts the buffer by removing data from completed rows.
-Moves any remaining data to the beginning of the buffer.
+ Returns the current position of the buffer.
+New data will be written into the buffer starting from this position.
@@ -880,7 +886,7 @@
intColumn
- Write an integer column with its value into the buffer of the sender.
+ Writes a 64-bit signed integer into the buffer.
+Use it to insert into LONG, INT, SHORT and BYTE columns.
@@ -1040,7 +1047,7 @@
reset
- Resets the buffer, data added to the buffer will be lost.
-In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ Resets the buffer, data sitting in the buffer will be lost.
+In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
@@ -1151,7 +1170,7 @@
- Extends the size of the sender's buffer.
-Can be used to increase the size of buffer if overflown.
-The buffer's content is copied into the new buffer.
-
-
-
-
-
-
-
-
-
-
-
Parameters:
-
-
-
-
-
-
-
Name
-
-
-
Type
-
-
-
-
-
-
Description
-
-
-
-
-
-
-
-
-
bufferSize
-
-
-
-
-
-
-
-
-
-
-
New size of the buffer used by the sender, provided in bytes
- Write a string column with its value into the buffer of the sender.
+ Writes a string column with its value into the buffer.
+Use it to insert into VARCHAR and STRING columns.
symbol
- Write a symbol name and value into the buffer of the sender.
+ Writes a symbol name and value into the buffer.
+Use it to insert into SYMBOL columns.
@@ -1661,7 +1536,7 @@
- Write a timestamp column with its value into the buffer of the sender.
+ Writes a timestamp column with its value into the buffer.
+Use it to insert into TIMESTAMP columns.
+ Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
+The returned buffer is a copy of this buffer.
+It also compacts the buffer.
+
+
+
+
+
+
+ Type
+
+
+
+Buffer
+
+
+
+
+
+
@@ -2262,16 +2114,13 @@
Parameters:
-
writeArray(arr)
+
toBufferView() → {Buffer}
-
- Writes array data to the buffer including dimensions and values.
-
- Writes a double-precision float to the buffer in little-endian format.
+
+ Returns a cropped buffer, or null if there is nothing to send.
+The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated.
+Used only in tests to assert the buffer's content.
- Write a float column with its value into the buffer using v1 format.
+ Writes a 64-bit floating point value into the buffer using v1 serialization (text format).
+Use it to insert into DOUBLE or FLOAT database columns.
- Error if value is not an array when provided
+ Error if array validation fails:
+- value is not an array
+- or the shape of the array is irregular: the length of sub-arrays are different
+- or the array is not homogeneous: its elements are not all the same type
@@ -353,11 +372,23 @@
Returns:
- Reference to this sender buffer for method chaining
+ Returns with a reference to this buffer.
- Write a float column with its value into the buffer using v2 binary format.
+ Writes a 64-bit floating point value into the buffer using v2 serialization (binary format).
+Use it to insert into DOUBLE or FLOAT database columns.
protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
When https or tcps used, the connection is secured with TLS encryption.
-
protocol_version: enum, accepted values: auto, 1, 2 - The line protocol version used for data serialization.
-Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and supports array columns.
+
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
+Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles.
When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
-TCP/TCPS connections default to version 1. Version 2 provides better performance for numeric data and enables array column support.
+TCP/TCPS connections default to version 1.
addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
If no port is specified, a default will be used.
@@ -123,6 +123,9 @@
SenderOptio
Other options
+
stdlib_http: enum, accepted values: on, off - With HTTP protocol the Undici library is used by default. By setting this option
+to on the client switches to node's core http and https modules.
+
max_name_len: integer - The maximum length of a table or column name, the Sender defaults this parameter to 127.
Recommended to use the same setting as the server, which also uses 127 by default.
@@ -228,8 +231,7 @@
Parameters:
- 'log' is a logging function used by the Sender.
Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
- 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
-An undici.Agent object is expected.
-If the legacy HTTP transport is used, a http.Agent or https.Agent object is expected.
+Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
@@ -270,7 +272,7 @@
- 'log' is a logging function used by the Sender.
Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
- 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
-A http.Agent or https.Agent object is expected.
+Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
@@ -454,7 +456,7 @@
Optional extra configuration.
- 'log' is a logging function used by the Sender.
- }in /**br>
+Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
- 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
-A http.Agent or https.Agent object is expected.
+Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
@@ -617,7 +619,7 @@
+ Resolves the protocol version, if it is set to 'auto'.
+If TCP transport is used, the protocol version will default to 1.
+In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions
+supported by the server, and the highest will be selected.
+When calling the /settings endpoint the timeout and TLs options are used from the options object.
+
TCP transport implementation for QuestDB line protocol.
-Supports both TCP and TCPS (TLS-encrypted) connections with JWK token authentication.
-Provides persistent connections with challenge-response authentication flow.
+
TCP transport implementation.
+Supports both plain TCP or secure TLS-encrypted connections with configurable JWK token authentication.
- Promise resolving to true if authentication is successful
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
(async) close()
@@ -361,8 +223,7 @@
(async) close
- Closes the TCP connection to the database.
-Data sitting in the Sender's buffer will be lost unless flush() is called before close().
+ Closes the TCP connection to the database.
@@ -406,7 +267,7 @@
- Promise that resolves when the connection is closed
-
-
-
-
-
@@ -460,8 +311,7 @@
connect
- Creates a TCP connection to the database with optional authentication.
-Handles both plain TCP and TLS-encrypted connections.
+ Creates a TCP connection to the database.
@@ -505,7 +355,7 @@
(async) send
- Sends data to QuestDB using HTTP POST with retry logic and authentication.
+ Sends data to QuestDB using HTTP POST.
@@ -260,13 +260,18 @@
- Determines if an HTTP status code should trigger a retry.
+ Simple logger to write log messages to the console.
+Supported logging levels are `error`, `warn`, `info` and `debug`.
+Throws an error if logging level is invalid.
- True if the status code indicates a retryable error
-
-
-
-
-
@@ -1295,7 +1409,7 @@
Returns:
-
log(level, message)
+
timestampToMicros(timestamp, unit)
@@ -1303,9 +1417,7 @@
log
- Simple logger to write log messages to the console.
-Supported logging levels are `error`, `warn`, `info` and `debug`.
-Throws an error if logging level is invalid.
+ Converts a timestamp from the specified unit to microseconds.
@@ -1341,22 +1453,13 @@
- The timestamp converted to microseconds
+ The timestamp converted to nanoseconds
@@ -1641,7 +1773,7 @@
Returns:
-
timestampToNanos(timestamp, unit)
+
validateArray(data, dimensions)
@@ -1649,7 +1781,11 @@
times
- Converts a timestamp from the specified unit to nanoseconds.
+ Validates an array structure.
+Validation fails if:
+- data is not an array
+- the array is irregular: the length of its sub-arrays are different
+- the array is not homogenous: the array contains mixed types
var node_fs = require('node:fs');
+var undici = require('undici');
var http = require('http');
var https = require('https');
var node_buffer = require('node:buffer');
-var node_fs = require('node:fs');
var net = require('node:net');
var tls = require('node:tls');
var crypto = require('node:crypto');
@@ -70,8 +70,8 @@
Source: index.js
* Supported logging levels are `error`, `warn`, `info` and `debug`. <br>
* Throws an error if logging level is invalid.
*
- * @param {'error'|'warn'|'info'|'debug'} level - The log level of the message.
- * @param {string | Error} message - The log message.
+ * @param {'error'|'warn'|'info'|'debug'} level - The log level for the message
+ * @param {string | Error} message - The message to log, either a string or Error object
*/ function log(level, message) {
const logLevel = LOG_LEVELS[level];
if (!logLevel) {
@@ -83,26 +83,24 @@
Source: index.js
}
/**
- * Supported primitive types for QuestDB arrays.
- */ /**
* Type guard to check if a value is a boolean.
- * @param value - The value to check
+ * @param {unknown} value - The value to check
* @returns True if the value is a boolean, false otherwise
*/ function isBoolean(value) {
return typeof value === "boolean";
}
/**
* Type guard to check if a value is an integer within specified bounds.
- * @param value - The value to check
- * @param lowerBound - The minimum allowed value (inclusive)
+ * @param {unknown} value - The value to check
+ * @param {number} lowerBound - The minimum allowed value (inclusive)
* @returns True if the value is an integer >= lowerBound, false otherwise
*/ function isInteger(value, lowerBound) {
return typeof value === "number" && Number.isInteger(value) && value >= lowerBound;
}
/**
* Converts a timestamp from the specified unit to microseconds.
- * @param timestamp - The timestamp value as a bigint
- * @param unit - The source timestamp unit
+ * @param {bigint} timestamp - The timestamp value as a bigint
+ * @param {TimestampUnit} unit - The source timestamp unit
* @returns The timestamp converted to microseconds
* @throws Error if the timestamp unit is unknown
*/ function timestampToMicros(timestamp, unit) {
@@ -119,8 +117,8 @@
Source: index.js
}
/**
* Converts a timestamp from the specified unit to nanoseconds.
- * @param timestamp - The timestamp value as a bigint
- * @param unit - The source timestamp unit
+ * @param {bigint} timestamp - The timestamp value as a bigint
+ * @param {TimestampUnit} unit - The source timestamp unit
* @returns The timestamp converted to nanoseconds
* @throws Error if the timestamp unit is unknown
*/ function timestampToNanos(timestamp, unit) {
@@ -137,21 +135,28 @@
Source: index.js
}
/**
* Analyzes the dimensions of a nested array structure.
- * @param data - The array to analyze
+ * @param {unknown} data - The array to analyze
* @returns Array of dimension sizes at each nesting level
* @throws Error if any dimension has zero length
*/ function getDimensions(data) {
const dimensions = [];
while(Array.isArray(data)){
- if (data.length === 0) {
- throw new Error("Zero length array not supported");
- }
dimensions.push(data.length);
data = data[0];
}
return dimensions;
}
-function validateArray(data, dimensions) {
+/**
+ * Validates an array structure. <br>
+ * Validation fails if:
+ * - <i>data</i> is not an array
+ * - the array is irregular: the length of its sub-arrays are different
+ * - the array is not homogenous: the array contains mixed types
+ * @param {unknown[]} data - The array to validate
+ * @param {number[]} dimensions - The shape of the array
+ * @returns The primitive type of the array's elements
+ * @throws Error if the validation fails
+ */ function validateArray(data, dimensions) {
if (data === null || data === undefined) {
return null;
}
@@ -162,7 +167,7 @@
Source: index.js
function checkArray(array, depth = 0, path = "") {
const expectedLength = dimensions[depth];
if (array.length !== expectedLength) {
- throw new Error(`Length of arrays do not match [expected=${expectedLength}, actual=${array.length}, dimensions=[${dimensions}], path=${path}]`);
+ throw new Error(`Lengths of sub-arrays do not match [expected=${expectedLength}, actual=${array.length}, dimensions=[${dimensions}], path=${path}]`);
}
if (depth < dimensions.length - 1) {
// intermediate level, expecting arrays
@@ -174,13 +179,13 @@
Source: index.js
}
} else {
// leaf level, expecting primitives
- if (expectedType === null) {
+ if (expectedType === null && array[0] !== undefined) {
expectedType = typeof array[0];
}
for(let i = 0; i < array.length; i++){
const currentType = typeof array[i];
if (currentType !== expectedType) {
- throw new Error(`Mixed types found [expected=${expectedType}, current=${currentType}, path=${path}[${i}]]`);
+ throw new Error(expectedType !== null ? `Mixed types found [expected=${expectedType}, current=${currentType}, path=${path}[${i}]]` : `Unsupported array type [type=${currentType}]`);
}
}
}
@@ -189,17 +194,27 @@
Source: index.js
return expectedType;
}
/**
- * Fetches JSON data from a URL with error handling.
+ * Fetches JSON data from a URL.
* @template T - The expected type of the JSON response
- * @param url - The URL to fetch from
+ * @param {string} url - The URL to fetch from
+ * @param {Agent} agent - HTTP agent to be used for the request
+ * @param {number} timeout - Request timeout, query will be aborted if not finished in time
* @returns Promise resolving to the parsed JSON data
* @throws Error if the request fails or returns a non-OK status
- */ async function fetchJson(url) {
+ */ async function fetchJson(url, timeout, agent) {
+ const controller = new AbortController();
+ const { signal } = controller;
+ const timeoutId = setTimeout(()=>controller.abort(), timeout);
let response;
try {
- response = await fetch(url);
+ response = await fetch(url, {
+ dispatcher: agent,
+ signal
+ });
} catch (error) {
throw new Error(`Failed to load ${url} [error=${error}]`);
+ } finally{
+ clearTimeout(timeoutId);
}
if (!response.ok) {
throw new Error(`Failed to load ${url} [statusCode=${response.status} (${response.statusText})]`);
@@ -207,6 +222,89 @@
Source: index.js
return await response.json();
}
+// @ts-check
+// HTTP status code for successful request with no content.
+const HTTP_NO_CONTENT = 204;
+// Default number of rows that trigger auto-flush for HTTP transport.
+const DEFAULT_HTTP_AUTO_FLUSH_ROWS = 75000;
+// Default minimum throughput for HTTP requests (100 KB/sec).
+const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400;
+// Default request timeout in milliseconds (10 seconds).
+const DEFAULT_REQUEST_TIMEOUT = 10000;
+// Default retry timeout in milliseconds (10 seconds).
+const DEFAULT_RETRY_TIMEOUT = 10000;
+// HTTP status codes that should trigger request retries.
+// Includes server errors and gateway timeouts that may be transient.
+const RETRIABLE_STATUS_CODES = [
+ 500,
+ 503,
+ 504,
+ 507,
+ 509,
+ 523,
+ 524,
+ 529,
+ 599
+];
+/**
+ * Abstract base class for HTTP-based transport implementations. <br>
+ * Provides common configuration and functionality for HTTP and HTTPS protocols.
+ */ class HttpTransportBase {
+ /**
+ * Creates a new HttpTransportBase instance.
+ *
+ * @param {SenderOptions} options - Sender configuration options including connection and authentication details
+ * @throws Error if required protocol or host options are missing
+ */ constructor(options){
+ if (!options || !options.protocol) {
+ throw new Error("The 'protocol' option is mandatory");
+ }
+ if (!options.host) {
+ throw new Error("The 'host' option is mandatory");
+ }
+ this.log = typeof options.log === "function" ? options.log : log;
+ this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
+ this.tlsCA = options.tls_ca ? node_fs.readFileSync(options.tls_ca) : undefined;
+ this.username = options.username;
+ this.password = options.password;
+ this.token = options.token;
+ if (!options.port) {
+ options.port = 9000;
+ }
+ this.host = options.host;
+ this.port = options.port;
+ this.requestMinThroughput = isInteger(options.request_min_throughput, 0) ? options.request_min_throughput : DEFAULT_REQUEST_MIN_THROUGHPUT;
+ this.requestTimeout = isInteger(options.request_timeout, 1) ? options.request_timeout : DEFAULT_REQUEST_TIMEOUT;
+ this.retryTimeout = isInteger(options.retry_timeout, 0) ? options.retry_timeout : DEFAULT_RETRY_TIMEOUT;
+ switch(options.protocol){
+ case HTTP:
+ this.secure = false;
+ break;
+ case HTTPS:
+ this.secure = true;
+ break;
+ default:
+ throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
+ }
+ }
+ /**
+ * HTTP transport does not require explicit connection establishment.
+ * @throws Error indicating connect is not required for HTTP transport
+ */ connect() {
+ throw new Error("'connect()' is not required for HTTP transport");
+ }
+ /**
+ * HTTP transport does not require explicit connection closure.
+ * @returns Promise that resolves immediately
+ */ async close() {}
+ /**
+ * Gets the default auto-flush row count for HTTP transport.
+ * @returns {number} Default number of rows that trigger auto-flush
+ */ getDefaultAutoFlushRows() {
+ return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
+ }
+}
+
const HTTP_PORT = 9000;
const TCP_PORT = 9009;
const HTTP = "http";
@@ -233,10 +331,10 @@
Source: index.js
* <li> <b>protocol</b>: <i>enum, accepted values: http, https, tcp, tcps</i> - The protocol used to communicate with the server. <br>
* When <i>https</i> or <i>tcps</i> used, the connection is secured with TLS encryption.
* </li>
- * <li> <b>protocol_version</b>: <i>enum, accepted values: auto, 1, 2</i> - The line protocol version used for data serialization. <br>
- * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and supports array columns. <br>
+ * <li> <b>protocol_version</b>: <i>enum, accepted values: auto, 1, 2</i> - The protocol version used for data serialization. <br>
+ * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles. <br>
* When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server. <br>
- * TCP/TCPS connections default to version 1. Version 2 provides better performance for numeric data and enables array column support.
+ * TCP/TCPS connections default to version 1.
* </li>
* <li> addr: <i>string</i> - Hostname and port, separated by colon. This key is mandatory, but the port part is optional. <br>
* If no port is specified, a default will be used. <br>
@@ -314,6 +412,9 @@
Source: index.js
* <br>
* Other options
* <ul>
+ * <li> stdlib_http: <i>enum, accepted values: on, off</i> - With HTTP protocol the Undici library is used by default. By setting this option
+ * to <i>on</i> the client switches to node's core http and https modules.
+ * </li>
* <li> max_name_len: <i>integer</i> - The maximum length of a table or column name, the Sender defaults this parameter to 127. <br>
* Recommended to use the same setting as the server, which also uses 127 by default.
* </li>
@@ -327,8 +428,7 @@
Source: index.js
* - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
* Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
* - 'agent' is a custom http/https agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * An <i>undici.Agent</i> object is expected. <br>
- * If the legacy HTTP transport is used, a <i>http.Agent</i> or <i>https.Agent</i> object is expected.
+ * Depends on which transport implementation and protocol used, one of the followings expected: <i>undici.Agent</i>, <i>http.Agent</i> or <i>https.Agent</i>.
*/ constructor(configurationString, extraOptions){
parseConfigurationString(this, configurationString);
if (extraOptions) {
@@ -343,13 +443,25 @@
Source: index.js
this.agent = extraOptions.agent;
}
}
- static async resolveAuto(options) {
+ /**
+ * Resolves the protocol version, if it is set to 'auto'. <br>
+ * If TCP transport is used, the protocol version will default to 1.
+ * In case of HTTP transport the <i>/settings</i> endpoint of the database is used to find the protocol versions
+ * supported by the server, and the highest will be selected.
+ * When calling the <i>/settings</i> endpoint the timeout and TLs options are used from the <i>options</i> object.
+ * @param {SenderOptions} options SenderOptions instance needs resolving protocol version
+ */ static async resolveAuto(options) {
parseProtocolVersion(options);
if (options.protocol_version !== PROTOCOL_VERSION_AUTO) {
return options;
}
const url = `${options.protocol}://${options.host}:${options.port}/settings`;
- const settings = await fetchJson(url);
+ const settings = await fetchJson(url, isInteger(options.request_timeout, 1) ? options.request_timeout : DEFAULT_REQUEST_TIMEOUT, new undici.Agent({
+ connect: {
+ ca: options.tls_ca ? node_fs.readFileSync(options.tls_ca) : undefined,
+ rejectUnauthorized: isBoolean(options.tls_verify) ? options.tls_verify : true
+ }
+ }));
const supportedVersions = (settings.config[LINE_PROTO_SUPPORT_VERSION] ?? []).map((version)=>String(version));
if (supportedVersions.length === 0) {
options.protocol_version = PROTOCOL_VERSION_V1;
@@ -389,7 +501,7 @@
Source: index.js
* - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
* Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
* - 'agent' is a custom http/https agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>http.Agent</i> or <i>https.Agent</i> object is expected.
+ * Depends on which transport implementation and protocol used, one of the followings expected: <i>undici.Agent</i>, <i>http.Agent</i> or <i>https.Agent</i>.
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
*/ static async fromConfig(configurationString, extraOptions) {
@@ -402,9 +514,9 @@
Source: index.js
*
* @param {object} extraOptions - Optional extra configuration. <br>
* - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
- }in /**br>
+ * Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
* - 'agent' is a custom http/https agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>http.Agent</i> or <i>https.Agent</i> object is expected.
+ * Depends on which transport implementation and protocol used, one of the followings expected: <i>undici.Agent</i>, <i>http.Agent</i> or <i>https.Agent</i>.
*
* @return {SenderOptions} A Sender configuration object initialized from the <b>QDB_CLIENT_CONF</b> environment variable.
*/ static async fromEnv(extraOptions) {
@@ -424,7 +536,7 @@
Source: index.js
parseTlsOptions(options);
parseRequestTimeoutOptions(options);
parseMaxNameLength(options);
- parseLegacyTransport(options);
+ parseStdlibTransport(options);
}
function parseSettings(options, configString, position) {
let index = configString.indexOf(";", position);
@@ -470,8 +582,8 @@
}
}
-// @ts-check
-// HTTP status code for successful request with no content.
-const HTTP_NO_CONTENT = 204;
-// Default number of rows that trigger auto-flush for HTTP transport.
-const DEFAULT_HTTP_AUTO_FLUSH_ROWS = 75000;
-// Default minimum throughput for HTTP requests (100 KB/sec).
-const DEFAULT_REQUEST_MIN_THROUGHPUT = 102400;
-// Default request timeout in milliseconds (10 seconds).
-const DEFAULT_REQUEST_TIMEOUT = 10000;
-// Default retry timeout in milliseconds (10 seconds).
-const DEFAULT_RETRY_TIMEOUT = 10000;
-// HTTP status codes that should trigger request retries.
-// Includes server errors and gateway timeouts that may be transient.
-const RETRIABLE_STATUS_CODES = [
- 500,
- 503,
- 504,
- 507,
- 509,
- 523,
- 524,
- 529,
- 599
-];
-/**
- * Abstract base class for HTTP-based transport implementations. <br>
- * Provides common configuration and functionality for HTTP and HTTPS protocols.
- */ class HttpTransportBase {
- /**
- * Creates a new HttpTransportBase instance.
- * @param options - Sender configuration options including connection and authentication details
- * @throws Error if required protocol or host options are missing
- */ constructor(options){
- if (!options || !options.protocol) {
- throw new Error("The 'protocol' option is mandatory");
- }
- if (!options.host) {
- throw new Error("The 'host' option is mandatory");
- }
- this.log = typeof options.log === "function" ? options.log : log;
- this.tlsVerify = isBoolean(options.tls_verify) ? options.tls_verify : true;
- this.tlsCA = options.tls_ca ? node_fs.readFileSync(options.tls_ca) : undefined;
- this.username = options.username;
- this.password = options.password;
- this.token = options.token;
- if (!options.port) {
- options.port = 9000;
- }
- this.host = options.host;
- this.port = options.port;
- this.requestMinThroughput = isInteger(options.request_min_throughput, 0) ? options.request_min_throughput : DEFAULT_REQUEST_MIN_THROUGHPUT;
- this.requestTimeout = isInteger(options.request_timeout, 1) ? options.request_timeout : DEFAULT_REQUEST_TIMEOUT;
- this.retryTimeout = isInteger(options.retry_timeout, 0) ? options.retry_timeout : DEFAULT_RETRY_TIMEOUT;
- switch(options.protocol){
- case HTTP:
- this.secure = false;
- break;
- case HTTPS:
- this.secure = true;
- break;
- default:
- throw new Error("The 'protocol' has to be 'http' or 'https' for the HTTP transport");
- }
- }
- /**
- * HTTP transport does not require explicit connection establishment.
- * @throws Error indicating connect is not required for HTTP transport
- */ connect() {
- throw new Error("'connect()' is not required for HTTP transport");
- }
- /**
- * HTTP transport does not require explicit connection closure.
- * @returns Promise that resolves immediately
- */ async close() {}
- /**
- * Gets the default auto-flush row count for HTTP transport.
- * @returns Default number of rows that trigger auto-flush
- */ getDefaultAutoFlushRows() {
- return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
- }
-}
-
// @ts-check
/**
* Default HTTP options for the Undici agent.
@@ -777,8 +807,9 @@
Source: index.js
});
}
/**
- * Sends data to QuestDB using HTTP POST with retry logic and authentication.
- * @param data - Buffer containing line protocol data to send
+ * Sends data to QuestDB using HTTP POST.
+ *
+ * @param {Buffer} data - Buffer containing the data to send
* @returns Promise resolving to true if data was sent successfully
* @throws Error if request fails after all retries or times out
*/ async send(data) {
@@ -815,7 +846,9 @@
Source: index.js
const body = await responseData.body.arrayBuffer();
if (statusCode === HTTP_NO_CONTENT) {
if (body.byteLength > 0) {
- this.log("warn", `Unexpected message from server: ${node_buffer.Buffer.from(body).toString()}`);
+ const message = node_buffer.Buffer.from(body).toString();
+ const logMessage = message.length < 256 ? message : `${message.substring(0, 256)}... (truncated, full length=${message.length})`;
+ this.log("warn", `Unexpected message from server: ${logMessage}`);
}
return true;
} else {
@@ -823,6 +856,7 @@
Source: index.js
}
}
/**
+ * @ignore
* Gets or creates the default HTTP agent with standard configuration.
* Uses a singleton pattern to reuse the same agent across instances.
* @returns The default Undici agent instance
@@ -837,21 +871,20 @@
Source: index.js
// @ts-check
// Default number of rows that trigger auto-flush for TCP transport.
const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600;
-// Arbitrary public key coordinates used to construct valid JWK tokens.
-// These are not used for actual authentication, only for crypto API compatibility.
+// Arbitrary public key, used to construct valid JWK tokens.
+// These are not used for actual authentication, only required for crypto API compatibility.
const PUBLIC_KEY = {
x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc",
y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg"
};
/**
- * TCP transport implementation for QuestDB line protocol.
- * Supports both TCP and TCPS (TLS-encrypted) connections with JWK token authentication.
- * Provides persistent connections with challenge-response authentication flow.
+ * TCP transport implementation. <br>
+ * Supports both plain TCP or secure TLS-encrypted connections with configurable JWK token authentication.
*/ class TcpTransport {
/**
* Creates a new TcpTransport instance.
*
- * @param options - Sender configuration object containing connection and authentication details
+ * @param {SenderOptions} options - Sender configuration object containing connection and authentication details
* @throws Error if required options are missing or protocol is not 'tcp' or 'tcps'
*/ constructor(options){
if (!options || !options.protocol) {
@@ -884,9 +917,7 @@
Source: index.js
}
}
/**
- * Creates a TCP connection to the database with optional authentication. <br>
- * Handles both plain TCP and TLS-encrypted connections.
- *
+ * Creates a TCP connection to the database.
* @returns Promise resolving to true if the connection is established successfully
* @throws Error if connection fails or authentication is rejected
*/ connect() {
@@ -941,9 +972,9 @@
Source: index.js
}
/**
* Sends data over the established TCP connection.
- * @param data - Buffer containing the data to send
+ * @param {Buffer} data - Buffer containing the data to send
* @returns Promise resolving to true if data was sent successfully
- * @throws Error if the connection is not established
+ * @throws Error if the data could not be written to the socket
*/ send(data) {
if (!this.socket || this.socket.destroyed) {
throw new Error("TCP transport is not connected");
@@ -959,9 +990,7 @@
Source: index.js
});
}
/**
- * Closes the TCP connection to the database. <br>
- * Data sitting in the Sender's buffer will be lost unless flush() is called before close().
- * @returns Promise that resolves when the connection is closed
+ * Closes the TCP connection to the database.
*/ async close() {
if (this.socket) {
const address = this.socket.remoteAddress;
@@ -978,8 +1007,9 @@
Source: index.js
return DEFAULT_TCP_AUTO_FLUSH_ROWS;
}
/**
+ * @ignore
* Handles the JWK token authentication challenge-response flow.
- * @param challenge - Challenge buffer received from the server
+ * @param {Buffer} challenge - Challenge buffer received from the server
* @returns Promise resolving to true if authentication is successful
*/ async authenticate(challenge) {
// Check for trailing \n which ends the challenge
@@ -1002,10 +1032,12 @@
Source: index.js
return false;
}
}
-// Constructs authentication configuration from username/token options.
-// @param options - Sender options that may contain authentication details
-// @throws Error if username or token is missing when authentication is intended
-function constructAuth(options) {
+/**
+ * @ignore
+ * Constructs authentication configuration from username/token options.
+ * @param {SenderOptions} options - Sender options that may contain authentication details
+ * @throws Error if username or token is missing when authentication is intended
+ */ function constructAuth(options) {
if (!options.username && !options.token && !options.password) {
// no intention to authenticate
return;
@@ -1018,11 +1050,13 @@
Source: index.js
token: options.token
};
}
-// Constructs a JWK (JSON Web Key) object for cryptographic authentication.
-// @param options - Sender options containing authentication configuration
-// @returns JWK object with key ID, private key, and public key coordinates
-// @throws Error if required authentication properties are missing or invalid
-function constructJwk(options) {
+/**
+ * @ignore
+ * Constructs a JWK (JSON Web Key) object for cryptographic authentication.
+ * @param {SenderOptions} options - Sender options containing authentication configuration
+ * @returns JWK object with key ID, private key, and public key coordinates
+ * @throws Error if required authentication properties are missing or invalid
+ */ function constructJwk(options) {
if (options.auth) {
if (!options.auth.keyId) {
throw new Error("Missing username, please, specify the 'keyId' property of the 'auth' config option. " + "For example: new Sender({protocol: 'tcp', host: 'host', auth: {keyId: 'username', token: 'private key'}})");
@@ -1059,14 +1093,13 @@
Source: index.js
timeout: 60000
};
/**
- * Legacy HTTP transport implementation using Node.js built-in http/https modules. <br>
- * Provides HTTP requests with manual retry logic and connection pooling. <br>
+ * HTTP transport implementation using Node.js built-in http/https modules. <br>
* Supports both HTTP and HTTPS protocols with configurable authentication.
*/ class HttpTransport extends HttpTransportBase {
/**
- * Creates a new HttpTransport instance using legacy Node.js HTTP modules.
+ * Creates a new HttpTransport instance using Node.js HTTP modules.
*
- * @param options - Sender configuration object containing connection details
+ * @param {SenderOptions} options - Sender configuration object containing connection details
* @throws Error if the protocol is not 'http' or 'https'
*/ constructor(options){
super(options);
@@ -1082,10 +1115,11 @@
Source: index.js
}
}
/**
- * Sends data to QuestDB using HTTP POST with manual retry logic.
- * @param data - Buffer containing line protocol data to send
- * @param retryBegin - Internal parameter for tracking retry start time
- * @param retryInterval - Internal parameter for tracking retry intervals
+ * Sends data to QuestDB using HTTP POST.
+ *
+ * @param {Buffer} data - Buffer containing the data to send
+ * @param {number} retryBegin - Internal parameter for tracking retry start time
+ * @param {number} retryInterval - Internal parameter for tracking retry intervals
* @returns Promise resolving to true if data was sent successfully
* @throws Error if request fails after all retries or times out
*/ send(data, retryBegin = -1, retryInterval = -1) {
@@ -1105,7 +1139,9 @@
};
}
/**
+ * @ignore
* Gets or creates the default HTTP agent with standard configuration.
* Uses a singleton pattern to reuse the same agent across instances.
* @returns The default HTTP agent instance
@@ -1179,6 +1217,7 @@
Source: index.js
return HttpTransport.DEFAULT_HTTP_AGENT;
}
/**
+ * @ignore
* Gets or creates the default HTTPS agent with standard configuration.
* Uses a singleton pattern to reuse the same agent across instances.
* @returns The default HTTPS agent instance
@@ -1190,8 +1229,9 @@
Source: index.js
}
}
/**
+ * @ignore
* Determines if an HTTP status code should trigger a retry.
- * @param statusCode - HTTP status code to check
+ * @param {number} statusCode - HTTP status code to check
* @returns True if the status code indicates a retryable error
*/ function isRetryable(statusCode) {
return RETRIABLE_STATUS_CODES.includes(statusCode);
@@ -1200,7 +1240,7 @@
Source: index.js
// @ts-check
/**
* Factory function to create appropriate transport instance based on configuration.
- * @param options - Sender configuration options including protocol and connection details
+ * @param {SenderOptions} options - Sender configuration options including protocol and connection details
* @returns Transport instance appropriate for the specified protocol
* @throws Error if protocol or host options are missing or invalid
*/ function createTransport(options) {
@@ -1213,7 +1253,7 @@
Source: index.js
switch(options.protocol){
case HTTP:
case HTTPS:
- return options.legacy_http ? new HttpTransport(options) : new UndiciTransport(options);
+ return options.stdlib_http ? new HttpTransport(options) : new UndiciTransport(options);
case TCP:
case TCPS:
return new TcpTransport(options);
@@ -1223,11 +1263,11 @@
Source: index.js
}
/**
- * Validates a table name. <br>
- * Throws an error if table name is invalid.
+ * Validates a table name.
*
* @param {string} name - The table name to validate.
* @param {number} maxNameLength - The maximum length of table names.
+ * @throws Error if table name is invalid.
*/ function validateTableName(name, maxNameLength) {
const len = name.length;
if (len > maxNameLength) {
@@ -1284,11 +1324,11 @@
Source: index.js
}
}
/**
- * Validates a column name. <br>
- * Throws an error if column name is invalid.
+ * Validates a column name.
*
* @param {string} name - The column name to validate.
* @param {number} maxNameLength - The maximum length of column names.
+ * @throws Error if column name is invalid.
*/ function validateColumnName(name, maxNameLength) {
const len = name.length;
if (len > maxNameLength) {
@@ -1341,14 +1381,14 @@
Source: index.js
// Default maximum length for table and column names.
const DEFAULT_MAX_NAME_LENGTH = 127;
/**
- * Abstract base class for QuestDB line protocol buffer implementations.
- * Provides common functionality for building line protocol messages including
- * table names, symbols, columns, and timestamps.
+ * Abstract base class for SenderBuffer implementations. <br>
+ * Provides common functionality for writing data into the buffer.
*/ class SenderBufferBase {
/**
* Creates an instance of SenderBufferBase.
*
- * @param options - Sender configuration object containing buffer and naming options
+ * @param {SenderOptions} options - Sender configuration object. <br>
+ * See SenderOptions documentation for detailed description of configuration options.
*/ constructor(options){
this.log = options && typeof options.log === "function" ? options.log : log;
SenderOptions.resolveDeprecated(options, this.log);
@@ -1358,32 +1398,29 @@
Source: index.js
this.reset();
}
/**
- * Extends the size of the sender's buffer.
- * Can be used to increase the size of buffer if overflown.
- * The buffer's content is copied into the new buffer.
+ * @ignore
+ * Resizes the buffer. <br>
+ * Can be used to increase the size of the buffer if data to be written would not fit.
+ * Creates a new buffer, and copies the content of the old buffer into the new one.
*
- * @param bufferSize - New size of the buffer used by the sender, provided in bytes
+ * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes
* @throws Error if the requested buffer size exceeds the maximum allowed size
*/ resize(bufferSize) {
if (bufferSize > this.maxBufferSize) {
throw new Error(`Max buffer size is ${this.maxBufferSize} bytes, requested buffer size: ${bufferSize}`);
}
this.bufferSize = bufferSize;
- // Allocating an extra byte because Buffer.write() does not fail if the length of the data to be written is
- // longer than the size of the buffer. It simply just writes whatever it can, and returns.
- // If we can write into the extra byte, that indicates buffer overflow.
- // See the check in the write() function.
- const newBuffer = node_buffer.Buffer.alloc(this.bufferSize + 1, 0);
+ const newBuffer = node_buffer.Buffer.alloc(this.bufferSize, 0);
if (this.buffer) {
this.buffer.copy(newBuffer);
}
this.buffer = newBuffer;
}
/**
- * Resets the buffer, data added to the buffer will be lost. <br>
- * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ * Resets the buffer, data sitting in the buffer will be lost. <br>
+ * In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
*
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/ reset() {
this.position = 0;
this.startNewRow();
@@ -1396,18 +1433,16 @@
Source: index.js
this.hasColumns = false;
}
/**
- * @ignore
- * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send.
- * The returned buffer is backed by the sender's buffer.
- * Used only in tests.
+ * @return {Buffer} Returns a cropped buffer, or null if there is nothing to send. <br>
+ * The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated.
+ * Used only in tests to assert the buffer's content.
*/ toBufferView(pos = this.endOfLastRow) {
return pos > 0 ? this.buffer.subarray(0, pos) : null;
}
/**
- * @ignore
- * @return {Buffer|null} Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
- * The returned buffer is a copy of the sender's buffer.
- * It also compacts the Sender's buffer.
+ * @return {Buffer} Returns a cropped buffer ready to send to the server, or null if there is nothing to send. <br>
+ * The returned buffer is a copy of this buffer.
+ * It also compacts the buffer.
*/ toBufferNew(pos = this.endOfLastRow) {
if (pos > 0) {
const data = node_buffer.Buffer.allocUnsafe(pos);
@@ -1418,10 +1453,10 @@
Source: index.js
return null;
}
/**
- * Write the table name into the buffer of the sender.
+ * Writes the table name into the buffer.
*
* @param {string} table - Table name.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/ table(table) {
if (typeof table !== "string") {
throw new Error(`Table name must be a string, received ${typeof table}`);
@@ -1438,11 +1473,12 @@
Source: index.js
return this;
}
/**
- * Write a symbol name and value into the buffer of the sender.
+ * Writes a symbol name and value into the buffer. <br>
+ * Use it to insert into SYMBOL columns.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/ symbol(name, value) {
if (typeof name !== "string") {
throw new Error(`Symbol name must be a string, received ${typeof name}`);
@@ -1464,11 +1500,12 @@
Source: index.js
return this;
}
/**
- * Write a string column with its value into the buffer of the sender.
+ * Writes a string column with its value into the buffer. <br>
+ * Use it to insert into VARCHAR and STRING columns.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/ stringColumn(name, value) {
this.writeColumn(name, value, ()=>{
this.checkCapacity([
@@ -1481,11 +1518,12 @@
Source: index.js
return this;
}
/**
- * Write a boolean column with its value into the buffer of the sender.
+ * Writes a boolean column with its value into the buffer. <br>
+ * Use it to insert into BOOLEAN columns.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/ booleanColumn(name, value) {
this.writeColumn(name, value, ()=>{
this.checkCapacity([], 1);
@@ -1494,11 +1532,13 @@
Source: index.js
return this;
}
/**
- * Write an integer column with its value into the buffer of the sender.
+ * Writes a 64-bit signed integer into the buffer. <br>
+ * Use it to insert into LONG, INT, SHORT and BYTE columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
+ * @throws Error if the value is not an integer
*/ intColumn(name, value) {
if (!Number.isInteger(value)) {
throw new Error(`Value must be an integer, received ${value}`);
@@ -1514,12 +1554,13 @@
Source: index.js
return this;
}
/**
- * Write a timestamp column with its value into the buffer of the sender.
+ * Writes a timestamp column with its value into the buffer. <br>
+ * Use it to insert into TIMESTAMP columns.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/ timestampColumn(name, value, unit = "us") {
if (typeof value !== "bigint" && !Number.isInteger(value)) {
throw new Error(`Value must be an integer or BigInt, received ${value}`);
@@ -1536,7 +1577,7 @@
Source: index.js
return this;
}
/**
- * Closing the row after writing the designated timestamp into the buffer of the sender.
+ * Closes the row after writing the designated timestamp into the buffer.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -1558,7 +1599,7 @@
Source: index.js
this.startNewRow();
}
/**
- * Closing the row without writing designated timestamp into the buffer of the sender. <br>
+ * Closes the row without writing designated timestamp into the buffer. <br>
* Designated timestamp will be populated by the server on this record.
*/ atNow() {
if (!this.hasSymbols && !this.hasColumns) {
@@ -1569,6 +1610,12 @@
Source: index.js
this.startNewRow();
}
/**
+ * Returns the current position of the buffer. <br>
+ * New data will be written into the buffer starting from this position.
+ */ currentPosition() {
+ return this.position;
+ }
+ /**
* Checks if the buffer has sufficient capacity for additional data and resizes if needed.
* @param data - Array of strings to calculate the required capacity for
* @param base - Base number of bytes to add to the calculation
@@ -1586,7 +1633,8 @@
Source: index.js
}
}
/**
- * Compacts the buffer by removing data from completed rows.
+ * @ignore
+ * Compacts the buffer by removing completed rows.
* Moves any remaining data to the beginning of the buffer.
*/ compact() {
if (this.endOfLastRow > 0) {
@@ -1596,9 +1644,10 @@
Source: index.js
}
}
/**
+ * @ignore
* Common logic for writing column data to the buffer.
* @param name - Column name
- * @param value - Column value
+ * @param value - Column value
* @param writeValue - Function to write the value portion to the buffer
* @param valueType - Optional expected type for validation
*/ writeColumn(name, value, writeValue, valueType) {
@@ -1619,63 +1668,36 @@
Source: index.js
this.writeEscaped(name);
this.write("=");
writeValue();
- this.assertBufferOverflow();
this.hasColumns = true;
}
/**
+ * @ignore
* Writes string data to the buffer at the current position.
* @param data - String data to write
*/ write(data) {
this.position += this.buffer.write(data, this.position);
}
/**
+ * @ignore
* Writes a single byte to the buffer at the current position.
* @param data - Byte value to write
*/ writeByte(data) {
this.position = this.buffer.writeInt8(data, this.position);
}
/**
+ * @ignore
* Writes a 32-bit integer to the buffer in little-endian format.
* @param data - Integer value to write
*/ writeInt(data) {
this.position = this.buffer.writeInt32LE(data, this.position);
}
/**
+ * @ignore
* Writes a double-precision float to the buffer in little-endian format.
* @param data - Double value to write
*/ writeDouble(data) {
this.position = this.buffer.writeDoubleLE(data, this.position);
}
- /**
- * Writes array data to the buffer including dimensions and values.
- * @param arr - Array to write to the buffer
- */ writeArray(arr, dimensions, type) {
- this.checkCapacity([], 1 + dimensions.length * 4);
- this.writeByte(dimensions.length);
- for(let i = 0; i < dimensions.length; i++){
- this.writeInt(dimensions[i]);
- }
- this.checkCapacity([], SenderBufferBase.arraySize(dimensions, type));
- this.writeArrayValues(arr, dimensions);
- }
- writeArrayValues(arr, dimensions) {
- if (Array.isArray(arr[0])) {
- for(let i = 0; i < arr.length; i++){
- this.writeArrayValues(arr[i], dimensions);
- }
- } else {
- const type = typeof arr[0];
- switch(type){
- case "number":
- for(let i = 0; i < arr.length; i++){
- this.position = this.buffer.writeDoubleLE(arr[i], this.position);
- }
- break;
- default:
- throw new Error(`Unsupported array type [type=${type}]`);
- }
- }
- }
writeEscaped(data, quoted = false) {
for (const ch of data){
if (ch > "\\") {
@@ -1711,47 +1733,27 @@
Source: index.js
}
}
}
- static arraySize(dimensions, type) {
- let numOfElements = 1;
- for(let i = 0; i < dimensions.length; i++){
- numOfElements *= dimensions[i];
- }
- switch(type){
- case "number":
- return numOfElements * 8;
- case "boolean":
- return numOfElements;
- case "string":
- // in case of string[] capacity check is done separately for each array element
- return 0;
- default:
- throw new Error(`Unsupported array type [type=${type}]`);
- }
- }
- assertBufferOverflow() {
- if (this.position > this.bufferSize) {
- // should never happen, if checkCapacity() is correctly used
- throw new Error(`Buffer overflow [position=${this.position}, bufferSize=${this.bufferSize}]`);
- }
- }
}
// @ts-check
/**
- * Buffer implementation for QuestDB line protocol version 1.
- * Supports basic column types but does not support array columns.
+ * Buffer implementation for protocol version 1.
+ * Sends floating point numbers in their text form.
*/ class SenderBufferV1 extends SenderBufferBase {
/**
* Creates a new SenderBufferV1 instance.
- * @param options - Sender configuration options
- */ constructor(options){
+ *
+ * @param {SenderOptions} options - Sender configuration object. <br>
+ * See SenderOptions documentation for detailed description of configuration options. */ constructor(options){
super(options);
}
/**
- * Write a float column with its value into the buffer using v1 format.
- * @param name - Column name
- * @param value - Float value to write
- * @returns Reference to this sender buffer for method chaining
+ * Writes a 64-bit floating point value into the buffer using v1 serialization (text format). <br>
+ * Use it to insert into DOUBLE or FLOAT database columns.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @return {Sender} Returns with a reference to this sender.
*/ floatColumn(name, value) {
this.writeColumn(name, value, ()=>{
const valueStr = value.toString();
@@ -1764,6 +1766,7 @@
Source: index.js
}
/**
* Array columns are not supported in protocol v1.
+ *
* @throws Error indicating arrays are not supported in v1
*/ arrayColumn() {
throw new Error("Arrays are not supported in protocol v1");
@@ -1780,20 +1783,24 @@
Source: index.js
// ASCII code for equals sign used in binary protocol.
const EQUALS_SIGN = "=".charCodeAt(0);
/**
- * Buffer implementation for QuestDB line protocol version 2.
- * Supports all column types including arrays with binary encoding for doubles.
+ * Buffer implementation for protocol version 2.
+ * Sends floating point numbers in binary form.
*/ class SenderBufferV2 extends SenderBufferBase {
/**
* Creates a new SenderBufferV2 instance.
- * @param options - Sender configuration options
+ *
+ * @param {SenderOptions} options - Sender configuration object. <br>
+ * See SenderOptions documentation for detailed description of configuration options.
*/ constructor(options){
super(options);
}
/**
- * Write a float column with its value into the buffer using v2 binary format.
- * @param name - Column name
- * @param value - Float value to write
- * @returns Reference to this sender buffer for method chaining
+ * Writes a 64-bit floating point value into the buffer using v2 serialization (binary format). <br>
+ * Use it to insert into DOUBLE or FLOAT database columns.
+ *
+ * @param {string} name - Column name.
+ * @param {number} value - Column value, accepts only number values.
+ * @returns {Sender} Returns with a reference to this buffer.
*/ floatColumn(name, value) {
this.writeColumn(name, value, ()=>{
this.checkCapacity([], 10);
@@ -1805,10 +1812,14 @@
Source: index.js
}
/**
* Write an array column with its values into the buffer using v2 format.
- * @param name - Column name
- * @param value - Array values to write (currently supports double arrays)
- * @returns Reference to this sender buffer for method chaining
- * @throws Error if value is not an array when provided
+ *
+ * @param {string} name - Column name
+ * @param {unknown[]} value - Array values to write (currently supports double arrays)
+ * @returns {Sender} Returns with a reference to this buffer.
+ * @throws Error if array validation fails:
+ * - value is not an array
+ * - or the shape of the array is irregular: the length of sub-arrays are different
+ * - or the array is not homogeneous: its elements are not all the same type
*/ arrayColumn(name, value) {
const dimensions = getDimensions(value);
const type = validateArray(value, dimensions);
@@ -1829,6 +1840,55 @@
Source: index.js
});
return this;
}
+ writeArray(arr, dimensions, type) {
+ this.checkCapacity([], 1 + dimensions.length * 4);
+ this.writeByte(dimensions.length);
+ for(let i = 0; i < dimensions.length; i++){
+ this.writeInt(dimensions[i]);
+ }
+ this.checkCapacity([], SenderBufferV2.arraySize(dimensions, type));
+ this.writeArrayValues(arr, dimensions);
+ }
+ writeArrayValues(arr, dimensions) {
+ if (Array.isArray(arr[0])) {
+ for(let i = 0; i < arr.length; i++){
+ this.writeArrayValues(arr[i], dimensions);
+ }
+ } else {
+ const type = arr[0] !== undefined ? typeof arr[0] : null;
+ switch(type){
+ case "number":
+ for(let i = 0; i < arr.length; i++){
+ this.position = this.buffer.writeDoubleLE(arr[i], this.position);
+ }
+ break;
+ case null:
+ break;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
+ }
+ static arraySize(dimensions, type) {
+ let numOfElements = 1;
+ for(let i = 0; i < dimensions.length; i++){
+ numOfElements *= dimensions[i];
+ }
+ switch(type){
+ case "number":
+ return numOfElements * 8;
+ case "boolean":
+ return numOfElements;
+ case "string":
+ // in case of string[] capacity check is done separately for each array element
+ return 0;
+ case null:
+ // empty array
+ return 0;
+ default:
+ throw new Error(`Unsupported array type [type=${type}]`);
+ }
+ }
}
// @ts-check
@@ -1838,7 +1898,10 @@
Source: index.js
const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
/**
* Factory function to create a SenderBuffer instance based on the protocol version.
- * @param options - Sender configuration options
+ *
+ * @param {SenderOptions} options - Sender configuration object. <br>
+ * See SenderOptions documentation for detailed description of configuration options.
+ *
* @returns A SenderBuffer instance appropriate for the specified protocol version
* @throws Error if protocol version is not specified or is unsupported
*/ function createBuffer(options) {
@@ -1888,8 +1951,8 @@
Source: index.js
* such as Nginx to enable encryption.
* </p>
* <p>
- * The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
- * serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
+ * The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
+ * serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
* performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS
* by setting the protocol_version to 'auto' (default behavior).
* </p>
@@ -1939,7 +2002,7 @@
Source: index.js
* Creates an instance of Sender.
*
* @param {SenderOptions} options - Sender configuration object. <br>
- * See SenderOptions documentation for detailed description of configuration options. <br>
+ * See SenderOptions documentation for detailed description of configuration options.
*/ constructor(options){
this.transport = createTransport(options);
this.buffer = createBuffer(options);
@@ -1950,35 +2013,35 @@
Source: index.js
this.reset();
}
/**
- * Creates a Sender options object by parsing the provided configuration string.
+ * Creates a Sender object by parsing the provided configuration string.
*
* @param {string} configurationString - Configuration string. <br>
* @param {object} extraOptions - Optional extra configuration. <br>
* - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
* Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
- * - 'agent' is a custom Undici agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>undici.Agent</i> object is expected.
+ * - 'agent' is a custom http/https agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
+ * Depends on which transport implementation and protocol used, one of the followings expected: <i>undici.Agent</i>, <i>http.Agent</i> or <i>https.Agent</i>.
*
* @return {Sender} A Sender object initialized from the provided configuration string.
*/ static async fromConfig(configurationString, extraOptions) {
return new Sender(await SenderOptions.fromConfig(configurationString, extraOptions));
}
/**
- * Creates a Sender options object by parsing the configuration string set in the <b>QDB_CLIENT_CONF</b> environment variable.
+ * Creates a Sender object by parsing the configuration string set in the <b>QDB_CLIENT_CONF</b> environment variable.
*
* @param {object} extraOptions - Optional extra configuration. <br>
* - 'log' is a logging function used by the <a href="Sender.html">Sender</a>. <br>
* Prototype: <i>(level: 'error'|'warn'|'info'|'debug', message: string) => void</i>. <br>
- * - 'agent' is a custom Undici agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
- * A <i>undici.Agent</i> object is expected.
+ * - 'agent' is a custom http/https agent used by the <a href="Sender.html">Sender</a> when http/https transport is used. <br>
+ * Depends on which transport implementation and protocol used, one of the followings expected: <i>undici.Agent</i>, <i>http.Agent</i> or <i>https.Agent</i>.
*
* @return {Sender} A Sender object initialized from the <b>QDB_CLIENT_CONF</b> environment variable.
*/ static async fromEnv(extraOptions) {
return new Sender(await SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions));
}
/**
- * Resets the buffer, data added to the buffer will be lost. <br>
- * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ * Resets the sender's buffer, data sitting in the buffer will be lost. <br>
+ * In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
*
* @return {Sender} Returns with a reference to this sender.
*/ reset() {
@@ -1994,7 +2057,7 @@
Source: index.js
return this.transport.connect();
}
/**
- * Sends the buffer's content to the database and compacts the buffer.
+ * Sends the content of the sender's buffer to the database and compacts the buffer.
* If the last row is not finished it stays in the sender's buffer.
*
* @return {Promise<boolean>} Resolves to true when there was data in the buffer to send, and it was sent successfully.
@@ -2008,13 +2071,17 @@
Source: index.js
await this.transport.send(dataToSend);
}
/**
- * Closes the TCP connection to the database. <br>
+ * Closes the connection to the database. <br>
* Data sitting in the Sender's buffer will be lost unless flush() is called before close().
*/ async close() {
+ const pos = this.buffer.currentPosition();
+ if (pos > 0) {
+ this.log("warn", `Buffer contains data which has not been flushed before closing the sender, and it will be lost [position=${pos}]`);
+ }
return this.transport.close();
}
/**
- * Write the table name into the buffer of the sender.
+ * Writes the table name into the buffer of the sender of the sender.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -2023,7 +2090,8 @@
Source: index.js
return this;
}
/**
- * Write a symbol name and value into the buffer of the sender.
+ * Writes a symbol name and value into the buffer of the sender. <br>
+ * Use it to insert into SYMBOL columns.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -2033,7 +2101,8 @@
Source: index.js
return this;
}
/**
- * Write a string column with its value into the buffer of the sender.
+ * Writes a string column with its value into the buffer of the sender. <br>
+ * Use it to insert into VARCHAR and STRING columns.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -2043,7 +2112,8 @@
Source: index.js
return this;
}
/**
- * Write a boolean column with its value into the buffer of the sender.
+ * Writes a boolean column with its value into the buffer of the sender. <br>
+ * Use it to insert into BOOLEAN columns.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -2053,7 +2123,8 @@
Source: index.js
return this;
}
/**
- * Write a float column with its value into the buffer of the sender.
+ * Writes a 64-bit floating point value into the buffer of the sender. <br>
+ * Use it to insert into DOUBLE or FLOAT database columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -2063,30 +2134,34 @@
Source: index.js
return this;
}
/**
- * Write an array column with its values into the buffer of the sender. <br>
- * <b>Note:</b> Array columns are only supported in protocol version 2. If using protocol version 1,
- * this method will throw an error.
+ * Writes an array column with its values into the buffer of the sender.
*
- * @param {string} name - Column name.
- * @param {unknown[]} value - Array values to be written. Currently supports arrays of numbers.
- * @return {Sender} Returns with a reference to this sender.
- * @throws {Error} If protocol version 1 is used, as arrays are not supported.
+ * @param {string} name - Column name
+ * @param {unknown[]} value - Array values to write (currently supports double arrays)
+ * @returns {Sender} Returns with a reference to this sender.
+ * @throws Error if arrays are not supported by the buffer implementation, or array validation fails:
+ * - value is not an array
+ * - or the shape of the array is irregular: the length of sub-arrays are different
+ * - or the array is not homogeneous: its elements are not all the same type
*/ arrayColumn(name, value) {
this.buffer.arrayColumn(name, value);
return this;
}
/**
- * Write an integer column with its value into the buffer of the sender.
+ * Writes a 64-bit signed integer into the buffer of the sender. <br>
+ * Use it to insert into LONG, INT, SHORT and BYTE columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
+ * @throws Error if the value is not an integer
*/ intColumn(name, value) {
this.buffer.intColumn(name, value);
return this;
}
/**
- * Write a timestamp column with its value into the buffer of the sender.
+ * Writes a timestamp column with its value into the buffer of the sender. <br>
+ * Use it to insert into TIMESTAMP columns.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
@@ -2097,7 +2172,7 @@
Source: index.js
return this;
}
/**
- * Closing the row after writing the designated timestamp into the buffer of the sender.
+ * Closes the row after writing the designated timestamp into the buffer of the sender.
*
* @param {number | bigint} timestamp - Designated epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
@@ -2108,7 +2183,7 @@
Source: index.js
await this.tryFlush();
}
/**
- * Closing the row without writing designated timestamp into the buffer of the sender. <br>
+ * Closes the row without writing designated timestamp into the buffer of the sender. <br>
* Designated timestamp will be populated by the server on this record.
*/ async atNow() {
this.buffer.atNow();
@@ -2139,13 +2214,13 @@
Source: index.js
diff --git a/src/buffer/base.ts b/src/buffer/base.ts
index ec9f5d8..6182a19 100644
--- a/src/buffer/base.ts
+++ b/src/buffer/base.ts
@@ -41,7 +41,8 @@ abstract class SenderBufferBase implements SenderBuffer {
/**
* Creates an instance of SenderBufferBase.
*
- * @param options - Sender configuration object containing buffer and naming options
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
*/
protected constructor(options: SenderOptions) {
this.log = options && typeof options.log === "function" ? options.log : log;
@@ -66,11 +67,12 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Extends the size of the buffer.
- * Can be used to increase the size of buffer if overflown.
- * The buffer's content is copied into the new buffer.
+ * @ignore
+ * Resizes the buffer.
+ * Can be used to increase the size of the buffer if data to be written would not fit.
+ * Creates a new buffer, and copies the content of the old buffer into the new one.
*
- * @param bufferSize - New size of the buffer used by the sender, provided in bytes
+ * @param {number} bufferSize - New size of the buffer used by the sender, provided in bytes
* @throws Error if the requested buffer size exceeds the maximum allowed size
*/
private resize(bufferSize: number) {
@@ -88,10 +90,10 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Resets the buffer, data added to the buffer will be lost.
- * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ * Resets the buffer, data sitting in the buffer will be lost.
+ * In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
*
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
reset(): SenderBuffer {
this.position = 0;
@@ -134,7 +136,7 @@ abstract class SenderBufferBase implements SenderBuffer {
* Writes the table name into the buffer.
*
* @param {string} table - Table name.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
table(table: string): SenderBuffer {
if (typeof table !== "string") {
@@ -156,7 +158,7 @@ abstract class SenderBufferBase implements SenderBuffer {
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
symbol(name: string, value: unknown): SenderBuffer {
if (typeof name !== "string") {
@@ -184,7 +186,7 @@ abstract class SenderBufferBase implements SenderBuffer {
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
stringColumn(name: string, value: string): SenderBuffer {
this.writeColumn(
@@ -207,7 +209,7 @@ abstract class SenderBufferBase implements SenderBuffer {
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
booleanColumn(name: string, value: boolean): SenderBuffer {
this.writeColumn(
@@ -228,16 +230,20 @@ abstract class SenderBufferBase implements SenderBuffer {
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
abstract floatColumn(name: string, value: number): SenderBuffer;
/**
* Writes an array column with its values into the buffer.
*
- * @param {string} name - Column name.
- * @param {unknown[]} value - Column value, accepts only arrays.
- * @return {Sender} Returns with a reference to this sender.
+ * @param {string} name - Column name
+ * @param {unknown[]} value - Array values to write (currently supports double arrays)
+ * @returns {SenderBuffer} Returns with a reference to this buffer.
+ * @throws Error if arrays are not supported by the buffer implementation, or array validation fails:
+ * - value is not an array
+ * - or the shape of the array is irregular: the length of sub-arrays are different
+ * - or the array is not homogeneous: its elements are not all the same type
*/
abstract arrayColumn(name: string, value: unknown[]): SenderBuffer;
@@ -247,7 +253,7 @@ abstract class SenderBufferBase implements SenderBuffer {
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
* @throws Error if the value is not an integer
*/
intColumn(name: string, value: number): SenderBuffer {
@@ -270,7 +276,7 @@ abstract class SenderBufferBase implements SenderBuffer {
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
* @param {string} [unit=us] - Timestamp unit. Supported values: 'ns' - nanoseconds, 'us' - microseconds, 'ms' - milliseconds. Defaults to 'us'.
- * @return {Sender} Returns with a reference to this sender.
+ * @return {SenderBuffer} Returns with a reference to this buffer.
*/
timestampColumn(
name: string,
@@ -359,7 +365,8 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
- * Compacts the buffer by removing data from completed rows.
+ * @ignore
+ * Compacts the buffer by removing completed rows.
* Moves any remaining data to the beginning of the buffer.
*/
private compact() {
@@ -371,9 +378,10 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
+ * @ignore
* Common logic for writing column data to the buffer.
* @param name - Column name
- * @param value - Column value
+ * @param value - Column value
* @param writeValue - Function to write the value portion to the buffer
* @param valueType - Optional expected type for validation
*/
@@ -404,6 +412,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
+ * @ignore
* Writes string data to the buffer at the current position.
* @param data - String data to write
*/
@@ -412,6 +421,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
+ * @ignore
* Writes a single byte to the buffer at the current position.
* @param data - Byte value to write
*/
@@ -420,6 +430,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
+ * @ignore
* Writes a 32-bit integer to the buffer in little-endian format.
* @param data - Integer value to write
*/
@@ -428,6 +439,7 @@ abstract class SenderBufferBase implements SenderBuffer {
}
/**
+ * @ignore
* Writes a double-precision float to the buffer in little-endian format.
* @param data - Double value to write
*/
diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts
index c407aed..16dfecf 100644
--- a/src/buffer/bufferv1.ts
+++ b/src/buffer/bufferv1.ts
@@ -10,8 +10,9 @@ import { SenderBufferBase } from "./base";
class SenderBufferV1 extends SenderBufferBase {
/**
* Creates a new SenderBufferV1 instance.
- * @param options - Sender configuration options
- */
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options. */
constructor(options: SenderOptions) {
super(options);
}
@@ -40,6 +41,7 @@ class SenderBufferV1 extends SenderBufferBase {
/**
* Array columns are not supported in protocol v1.
+ *
* @throws Error indicating arrays are not supported in v1
*/
arrayColumn(): SenderBuffer {
diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts
index ab69708..9f471cb 100644
--- a/src/buffer/bufferv2.ts
+++ b/src/buffer/bufferv2.ts
@@ -22,7 +22,9 @@ const EQUALS_SIGN: number = "=".charCodeAt(0);
class SenderBufferV2 extends SenderBufferBase {
/**
* Creates a new SenderBufferV2 instance.
- * @param options - Sender configuration options
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
*/
constructor(options: SenderOptions) {
super(options);
@@ -34,7 +36,7 @@ class SenderBufferV2 extends SenderBufferBase {
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
- * @return {Sender} Returns with a reference to this sender.
+ * @returns {Sender} Returns with a reference to this buffer.
*/
floatColumn(name: string, value: number): SenderBuffer {
this.writeColumn(
@@ -53,9 +55,10 @@ class SenderBufferV2 extends SenderBufferBase {
/**
* Write an array column with its values into the buffer using v2 format.
- * @param name - Column name
- * @param value - Array values to write (currently supports double arrays)
- * @returns Reference to this buffer for method chaining
+ *
+ * @param {string} name - Column name
+ * @param {unknown[]} value - Array values to write (currently supports double arrays)
+ * @returns {Sender} Returns with a reference to this buffer.
* @throws Error if array validation fails:
* - value is not an array
* - or the shape of the array is irregular: the length of sub-arrays are different
diff --git a/src/buffer/index.ts b/src/buffer/index.ts
index c23c91c..f35c78a 100644
--- a/src/buffer/index.ts
+++ b/src/buffer/index.ts
@@ -19,7 +19,10 @@ const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB
/**
* Factory function to create a SenderBuffer instance based on the protocol version.
- * @param options - Sender configuration options
+ *
+ * @param {SenderOptions} options - Sender configuration object.
+ * See SenderOptions documentation for detailed description of configuration options.
+ *
* @returns A SenderBuffer instance appropriate for the specified protocol version
* @throws Error if protocol version is not specified or is unsupported
*/
@@ -48,8 +51,8 @@ function createBuffer(options: SenderOptions): SenderBuffer {
*/
interface SenderBuffer {
/**
- * Resets the buffer, data added to the buffer will be lost.
- * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ * Resets the buffer, data sitting in the buffer will be lost.
+ * In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
*
* @return {Sender} Returns with a reference to this sender.
*/
@@ -78,7 +81,8 @@ interface SenderBuffer {
table(table: string): SenderBuffer;
/**
- * Writes a symbol name and value into the buffer.
+ * Writes a symbol name and value into the buffer.
+ * Use it to insert into SYMBOL columns.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -87,7 +91,8 @@ interface SenderBuffer {
symbol(name: string, value: unknown): SenderBuffer;
/**
- * Writes a string column with its value into the buffer.
+ * Writes a string column with its value into the buffer.
+ * Use it to insert into VARCHAR and STRING columns.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -96,7 +101,8 @@ interface SenderBuffer {
stringColumn(name: string, value: string): SenderBuffer;
/**
- * Writes a boolean column with its value into the buffer.
+ * Writes a boolean column with its value into the buffer.
+ * Use it to insert into BOOLEAN columns.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -105,7 +111,8 @@ interface SenderBuffer {
booleanColumn(name: string, value: boolean): SenderBuffer;
/**
- * Writes a float column with its value into the buffer.
+ * Writes a 64-bit floating point value into the buffer.
+ * Use it to insert into DOUBLE or FLOAT database columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -114,16 +121,21 @@ interface SenderBuffer {
floatColumn(name: string, value: number): SenderBuffer;
/**
- * Write an array column with its values into the buffer of the sender.
+ * Writes an array column with its values into the buffer.
*
- * @param {string} name - Column name.
- * @param {unknown[]} value - Array values to be written.
- * @return {SenderBuffer} Returns with a reference to this sender buffer.
+ * @param {string} name - Column name
+ * @param {unknown[]} value - Array values to write (currently supports double arrays)
+ * @returns {Sender} Returns with a reference to this buffer.
+ * @throws Error if arrays are not supported by the buffer implementation, or array validation fails:
+ * - value is not an array
+ * - or the shape of the array is irregular: the length of sub-arrays are different
+ * - or the array is not homogeneous: its elements are not all the same type
*/
arrayColumn(name: string, value: unknown[]): SenderBuffer;
/**
- * Writes an integer column with its value into the buffer.
+ * Writes a 64-bit signed integer into the buffer.
+ * Use it to insert into LONG, INT, SHORT and BYTE columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -133,7 +145,8 @@ interface SenderBuffer {
intColumn(name: string, value: number): SenderBuffer;
/**
- * Writes a timestamp column with its value into the buffer.
+ * Writes a timestamp column with its value into the buffer.
+ * Use it to insert into TIMESTAMP columns.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
diff --git a/src/logging.ts b/src/logging.ts
index 743c95a..e9762fc 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -12,8 +12,9 @@ const DEFAULT_CRITICALITY = LOG_LEVELS.info.criticality;
/**
* Logger function type definition.
- * @param level - The log level for the message
- * @param message - The message to log, either a string or Error object
+ *
+ * @param {'error'|'warn'|'info'|'debug'} level - The log level for the message
+ * @param {string | Error} message - The message to log, either a string or Error object
*/
type Logger = (
level: "error" | "warn" | "info" | "debug",
@@ -25,8 +26,8 @@ type Logger = (
* Supported logging levels are `error`, `warn`, `info` and `debug`.
* Throws an error if logging level is invalid.
*
- * @param {'error'|'warn'|'info'|'debug'} level - The log level of the message.
- * @param {string | Error} message - The log message.
+ * @param {'error'|'warn'|'info'|'debug'} level - The log level for the message
+ * @param {string | Error} message - The message to log, either a string or Error object
*/
function log(
level: "error" | "warn" | "info" | "debug",
diff --git a/src/options.ts b/src/options.ts
index 9801bae..cca9743 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -197,8 +197,7 @@ class SenderOptions {
* - 'log' is a logging function used by the Sender.
* Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
* - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
- * An undici.Agent object is expected.
- * If the standard HTTP transport is used, a http.Agent or https.Agent object is expected.
+ * Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
*/
constructor(configurationString: string, extraOptions?: ExtraOptions) {
parseConfigurationString(this, configurationString);
@@ -225,9 +224,10 @@ class SenderOptions {
/**
* Resolves the protocol version, if it is set to 'auto'.
* If TCP transport is used, the protocol version will default to 1.
- * In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions
+ * In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions
* supported by the server, and the highest will be selected.
- * @param options SenderOptions instance needs resolving protocol version
+ * When calling the /settings endpoint the timeout and TLs options are used from the options object.
+ * @param {SenderOptions} options SenderOptions instance needs resolving protocol version
*/
static async resolveAuto(options: SenderOptions) {
parseProtocolVersion(options);
@@ -312,7 +312,7 @@ class SenderOptions {
* - 'log' is a logging function used by the Sender.
* Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
* - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
- * A http.Agent or https.Agent object is expected.
+ * Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
*
* @return {SenderOptions} A Sender configuration object initialized from the provided configuration string.
*/
@@ -330,9 +330,9 @@ class SenderOptions {
*
* @param {object} extraOptions - Optional extra configuration.
* - 'log' is a logging function used by the Sender.
- }in /**br>
+ * Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
* - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
- * A http.Agent or https.Agent object is expected.
+ * Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
*
* @return {SenderOptions} A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.
*/
diff --git a/src/sender.ts b/src/sender.ts
index 9eb9c3f..b2f22c7 100644
--- a/src/sender.ts
+++ b/src/sender.ts
@@ -36,8 +36,8 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec
* such as Nginx to enable encryption.
*
*
- * The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
- * serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
+ * The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based
+ * serialization, while version 2 uses binary encoding for doubles and supports array columns for improved
* performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS
* by setting the protocol_version to 'auto' (default behavior).
*
@@ -100,7 +100,7 @@ class Sender {
* Creates an instance of Sender.
*
* @param {SenderOptions} options - Sender configuration object.
- * See SenderOptions documentation for detailed description of configuration options.
+ * See SenderOptions documentation for detailed description of configuration options.
*/
constructor(options: SenderOptions) {
this.transport = createTransport(options);
@@ -120,14 +120,14 @@ class Sender {
}
/**
- * Creates a Sender options object by parsing the provided configuration string.
+ * Creates a Sender object by parsing the provided configuration string.
*
* @param {string} configurationString - Configuration string.
* @param {object} extraOptions - Optional extra configuration.
* - 'log' is a logging function used by the Sender.
* Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
- * - 'agent' is a custom Undici agent used by the Sender when http/https transport is used.
- * A undici.Agent object is expected.
+ * - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
+ * Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
*
* @return {Sender} A Sender object initialized from the provided configuration string.
*/
@@ -141,13 +141,13 @@ class Sender {
}
/**
- * Creates a Sender options object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.
+ * Creates a Sender object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.
*
* @param {object} extraOptions - Optional extra configuration.
* - 'log' is a logging function used by the Sender.
* Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
- * - 'agent' is a custom Undici agent used by the Sender when http/https transport is used.
- * A undici.Agent object is expected.
+ * - 'agent' is a custom http/https agent used by the Sender when http/https transport is used.
+ * Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
*
* @return {Sender} A Sender object initialized from the QDB_CLIENT_CONF environment variable.
*/
@@ -158,8 +158,8 @@ class Sender {
}
/**
- * Resets the buffer, data added to the buffer will be lost.
- * In other words it clears the buffer and sets the writing position to the beginning of the buffer.
+ * Resets the sender's buffer, data sitting in the buffer will be lost.
+ * In other words it clears the buffer, and sets the writing position to the beginning of the buffer.
*
* @return {Sender} Returns with a reference to this sender.
*/
@@ -179,7 +179,7 @@ class Sender {
}
/**
- * Sends the buffer's content to the database and compacts the buffer.
+ * Sends the content of the sender's buffer to the database and compacts the buffer.
* If the last row is not finished it stays in the sender's buffer.
*
* @return {Promise} Resolves to true when there was data in the buffer to send, and it was sent successfully.
@@ -215,7 +215,7 @@ class Sender {
}
/**
- * Writes the table name into the buffer of the sender.
+ * Writes the table name into the buffer of the sender of the sender.
*
* @param {string} table - Table name.
* @return {Sender} Returns with a reference to this sender.
@@ -226,7 +226,8 @@ class Sender {
}
/**
- * Writes a symbol name and value into the buffer of the sender.
+ * Writes a symbol name and value into the buffer of the sender.
+ * Use it to insert into SYMBOL columns.
*
* @param {string} name - Symbol name.
* @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter.
@@ -238,7 +239,8 @@ class Sender {
}
/**
- * Writes a string column with its value into the buffer of the sender.
+ * Writes a string column with its value into the buffer of the sender.
+ * Use it to insert into VARCHAR and STRING columns.
*
* @param {string} name - Column name.
* @param {string} value - Column value, accepts only string values.
@@ -250,7 +252,8 @@ class Sender {
}
/**
- * Writes a boolean column with its value into the buffer of the sender.
+ * Writes a boolean column with its value into the buffer of the sender.
+ * Use it to insert into BOOLEAN columns.
*
* @param {string} name - Column name.
* @param {boolean} value - Column value, accepts only boolean values.
@@ -262,7 +265,8 @@ class Sender {
}
/**
- * Writes a float column with its value into the buffer of the sender.
+ * Writes a 64-bit floating point value into the buffer of the sender.
+ * Use it to insert into DOUBLE or FLOAT database columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
@@ -274,14 +278,15 @@ class Sender {
}
/**
- * Write an array column with its values into the buffer of the sender.
- * Note: Array columns are only supported in protocol version 2. If using protocol version 1,
- * this method will throw an error.
+ * Writes an array column with its values into the buffer of the sender.
*
- * @param {string} name - Column name.
- * @param {unknown[]} value - Array values to be written. Currently supports arrays of numbers.
- * @return {Sender} Returns with a reference to this sender.
- * @throws {Error} If protocol version 1 is used, as arrays are not supported.
+ * @param {string} name - Column name
+ * @param {unknown[]} value - Array values to write (currently supports double arrays)
+ * @returns {Sender} Returns with a reference to this sender.
+ * @throws Error if arrays are not supported by the buffer implementation, or array validation fails:
+ * - value is not an array
+ * - or the shape of the array is irregular: the length of sub-arrays are different
+ * - or the array is not homogeneous: its elements are not all the same type
*/
arrayColumn(name: string, value: unknown[]): Sender {
this.buffer.arrayColumn(name, value);
@@ -289,11 +294,13 @@ class Sender {
}
/**
- * Writes an integer column with its value into the buffer of the sender.
+ * Writes a 64-bit signed integer into the buffer of the sender.
+ * Use it to insert into LONG, INT, SHORT and BYTE columns.
*
* @param {string} name - Column name.
* @param {number} value - Column value, accepts only number values.
* @return {Sender} Returns with a reference to this sender.
+ * @throws Error if the value is not an integer
*/
intColumn(name: string, value: number): Sender {
this.buffer.intColumn(name, value);
@@ -301,7 +308,8 @@ class Sender {
}
/**
- * Writes a timestamp column with its value into the buffer of the sender.
+ * Writes a timestamp column with its value into the buffer of the sender.
+ * Use it to insert into TIMESTAMP columns.
*
* @param {string} name - Column name.
* @param {number | bigint} value - Epoch timestamp, accepts numbers or BigInts.
diff --git a/src/transport/http/base.ts b/src/transport/http/base.ts
index 7eb4f00..c772899 100644
--- a/src/transport/http/base.ts
+++ b/src/transport/http/base.ts
@@ -50,7 +50,8 @@ abstract class HttpTransportBase implements SenderTransport {
/**
* Creates a new HttpTransportBase instance.
- * @param options - Sender configuration options including connection and authentication details
+ *
+ * @param {SenderOptions} options - Sender configuration options including connection and authentication details
* @throws Error if required protocol or host options are missing
*/
protected constructor(options: SenderOptions) {
@@ -115,7 +116,7 @@ abstract class HttpTransportBase implements SenderTransport {
/**
* Gets the default auto-flush row count for HTTP transport.
- * @returns Default number of rows that trigger auto-flush
+ * @returns {number} Default number of rows that trigger auto-flush
*/
getDefaultAutoFlushRows(): number {
return DEFAULT_HTTP_AUTO_FLUSH_ROWS;
@@ -124,7 +125,7 @@ abstract class HttpTransportBase implements SenderTransport {
/**
* Sends data to the QuestDB server via HTTP.
* Must be implemented by concrete HTTP transport classes.
- * @param data - Buffer containing the data to send
+ * @param {Buffer} data - Buffer containing the data to send
* @returns Promise resolving to true if data was sent successfully
*/
abstract send(data: Buffer): Promise;
diff --git a/src/transport/http/stdlib.ts b/src/transport/http/stdlib.ts
index 8fe0b9e..af840e6 100644
--- a/src/transport/http/stdlib.ts
+++ b/src/transport/http/stdlib.ts
@@ -34,7 +34,7 @@ class HttpTransport extends HttpTransportBase {
/**
* Creates a new HttpTransport instance using Node.js HTTP modules.
*
- * @param options - Sender configuration object containing connection details
+ * @param {SenderOptions} options - Sender configuration object containing connection details
* @throws Error if the protocol is not 'http' or 'https'
*/
constructor(options: SenderOptions) {
@@ -62,13 +62,18 @@ class HttpTransport extends HttpTransportBase {
/**
* Sends data to QuestDB using HTTP POST.
- * @param data - Buffer containing data to send
- * @param retryBegin - Internal parameter for tracking retry start time
- * @param retryInterval - Internal parameter for tracking retry intervals
+ *
+ * @param {Buffer} data - Buffer containing the data to send
+ * @param {number} retryBegin - Internal parameter for tracking retry start time
+ * @param {number} retryInterval - Internal parameter for tracking retry intervals
* @returns Promise resolving to true if data was sent successfully
* @throws Error if request fails after all retries or times out
*/
- send(data: Buffer, retryBegin = -1, retryInterval = -1): Promise {
+ send(
+ data: Buffer,
+ retryBegin: number = -1,
+ retryInterval: number = -1,
+ ): Promise {
const request = this.secure ? https.request : http.request;
const timeoutMillis =
@@ -158,8 +163,10 @@ class HttpTransport extends HttpTransportBase {
}
/**
+ * @ignore
* Creates HTTP request options based on configuration.
- * @param timeoutMillis - Request timeout in milliseconds
+ *
+ * @param {number} timeoutMillis - Request timeout in milliseconds
* @returns HTTP or HTTPS request options object
*/
private createRequestOptions(
@@ -178,6 +185,7 @@ class HttpTransport extends HttpTransportBase {
}
/**
+ * @ignore
* Gets or creates the default HTTP agent with standard configuration.
* Uses a singleton pattern to reuse the same agent across instances.
* @returns The default HTTP agent instance
@@ -192,6 +200,7 @@ class HttpTransport extends HttpTransportBase {
}
/**
+ * @ignore
* Gets or creates the default HTTPS agent with standard configuration.
* Uses a singleton pattern to reuse the same agent across instances.
* @returns The default HTTPS agent instance
@@ -207,8 +216,9 @@ class HttpTransport extends HttpTransportBase {
}
/**
+ * @ignore
* Determines if an HTTP status code should trigger a retry.
- * @param statusCode - HTTP status code to check
+ * @param {number} statusCode - HTTP status code to check
* @returns True if the status code indicates a retryable error
*/
function isRetryable(statusCode: number) {
diff --git a/src/transport/http/undici.ts b/src/transport/http/undici.ts
index a8dc119..a24dadb 100644
--- a/src/transport/http/undici.ts
+++ b/src/transport/http/undici.ts
@@ -94,7 +94,8 @@ class UndiciTransport extends HttpTransportBase {
/**
* Sends data to QuestDB using HTTP POST.
- * @param data - Buffer containing data to send
+ *
+ * @param {Buffer} data - Buffer containing the data to send
* @returns Promise resolving to true if data was sent successfully
* @throws Error if request fails after all retries or times out
*/
@@ -155,6 +156,7 @@ class UndiciTransport extends HttpTransportBase {
}
/**
+ * @ignore
* Gets or creates the default HTTP agent with standard configuration.
* Uses a singleton pattern to reuse the same agent across instances.
* @returns The default Undici agent instance
diff --git a/src/transport/index.ts b/src/transport/index.ts
index 380040a..e81a7db 100644
--- a/src/transport/index.ts
+++ b/src/transport/index.ts
@@ -19,8 +19,8 @@ interface SenderTransport {
connect(): Promise;
/**
- * Sends buffered data to the database server.
- * @param data - Buffer containing the data to send
+ * Sends the data to the database server.
+ * @param {Buffer} data - Buffer containing the data to send
* @returns Promise resolving to true if data was sent successfully
*/
send(data: Buffer): Promise;
@@ -41,7 +41,7 @@ interface SenderTransport {
/**
* Factory function to create appropriate transport instance based on configuration.
- * @param options - Sender configuration options including protocol and connection details
+ * @param {SenderOptions} options - Sender configuration options including protocol and connection details
* @returns Transport instance appropriate for the specified protocol
* @throws Error if protocol or host options are missing or invalid
*/
diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts
index d2bc13a..57b5026 100644
--- a/src/transport/tcp.ts
+++ b/src/transport/tcp.ts
@@ -40,7 +40,7 @@ class TcpTransport implements SenderTransport {
/**
* Creates a new TcpTransport instance.
*
- * @param options - Sender configuration object containing connection and authentication details
+ * @param {SenderOptions} options - Sender configuration object containing connection and authentication details
* @throws Error if required options are missing or protocol is not 'tcp' or 'tcps'
*/
constructor(options: SenderOptions) {
@@ -156,7 +156,7 @@ class TcpTransport implements SenderTransport {
/**
* Sends data over the established TCP connection.
- * @param data - Buffer containing the data to send
+ * @param {Buffer} data - Buffer containing the data to send
* @returns Promise resolving to true if data was sent successfully
* @throws Error if the data could not be written to the socket
*/
@@ -176,7 +176,7 @@ class TcpTransport implements SenderTransport {
}
/**
- * Closes the TCP connection to the database.
+ * Closes the TCP connection to the database.
*/
async close(): Promise {
if (this.socket) {
@@ -197,8 +197,9 @@ class TcpTransport implements SenderTransport {
}
/**
+ * @ignore
* Handles the JWK token authentication challenge-response flow.
- * @param challenge - Challenge buffer received from the server
+ * @param {Buffer} challenge - Challenge buffer received from the server
* @returns Promise resolving to true if authentication is successful
*/
private async authenticate(challenge: Buffer): Promise {
@@ -231,9 +232,12 @@ class TcpTransport implements SenderTransport {
}
}
-// Constructs authentication configuration from username/token options.
-// @param options - Sender options that may contain authentication details
-// @throws Error if username or token is missing when authentication is intended
+/**
+ * @ignore
+ * Constructs authentication configuration from username/token options.
+ * @param {SenderOptions} options - Sender options that may contain authentication details
+ * @throws Error if username or token is missing when authentication is intended
+ */
function constructAuth(options: SenderOptions) {
if (!options.username && !options.token && !options.password) {
// no intention to authenticate
@@ -252,10 +256,13 @@ function constructAuth(options: SenderOptions) {
};
}
-// Constructs a JWK (JSON Web Key) object for cryptographic authentication.
-// @param options - Sender options containing authentication configuration
-// @returns JWK object with key ID, private key, and public key coordinates
-// @throws Error if required authentication properties are missing or invalid
+/**
+ * @ignore
+ * Constructs a JWK (JSON Web Key) object for cryptographic authentication.
+ * @param {SenderOptions} options - Sender options containing authentication configuration
+ * @returns JWK object with key ID, private key, and public key coordinates
+ * @throws Error if required authentication properties are missing or invalid
+ */
function constructJwk(options: SenderOptions) {
if (options.auth) {
if (!options.auth.keyId) {
diff --git a/src/utils.ts b/src/utils.ts
index a716cb3..d7f580c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -13,7 +13,7 @@ type TimestampUnit = "ns" | "us" | "ms";
/**
* Type guard to check if a value is a boolean.
- * @param value - The value to check
+ * @param {unknown} value - The value to check
* @returns True if the value is a boolean, false otherwise
*/
function isBoolean(value: unknown): value is boolean {
@@ -22,8 +22,8 @@ function isBoolean(value: unknown): value is boolean {
/**
* Type guard to check if a value is an integer within specified bounds.
- * @param value - The value to check
- * @param lowerBound - The minimum allowed value (inclusive)
+ * @param {unknown} value - The value to check
+ * @param {number} lowerBound - The minimum allowed value (inclusive)
* @returns True if the value is an integer >= lowerBound, false otherwise
*/
function isInteger(value: unknown, lowerBound: number): value is number {
@@ -34,8 +34,8 @@ function isInteger(value: unknown, lowerBound: number): value is number {
/**
* Converts a timestamp from the specified unit to microseconds.
- * @param timestamp - The timestamp value as a bigint
- * @param unit - The source timestamp unit
+ * @param {bigint} timestamp - The timestamp value as a bigint
+ * @param {TimestampUnit} unit - The source timestamp unit
* @returns The timestamp converted to microseconds
* @throws Error if the timestamp unit is unknown
*/
@@ -54,8 +54,8 @@ function timestampToMicros(timestamp: bigint, unit: TimestampUnit) {
/**
* Converts a timestamp from the specified unit to nanoseconds.
- * @param timestamp - The timestamp value as a bigint
- * @param unit - The source timestamp unit
+ * @param {bigint} timestamp - The timestamp value as a bigint
+ * @param {TimestampUnit} unit - The source timestamp unit
* @returns The timestamp converted to nanoseconds
* @throws Error if the timestamp unit is unknown
*/
@@ -74,7 +74,7 @@ function timestampToNanos(timestamp: bigint, unit: TimestampUnit) {
/**
* Analyzes the dimensions of a nested array structure.
- * @param data - The array to analyze
+ * @param {unknown} data - The array to analyze
* @returns Array of dimension sizes at each nesting level
* @throws Error if any dimension has zero length
*/
@@ -92,9 +92,9 @@ function getDimensions(data: unknown) {
* Validation fails if:
* - data is not an array
* - the array is irregular: the length of its sub-arrays are different
- * - the array is not homogenous: the array contains mixed types
- * @param data - The array to validate
- * @param dimensions - The shape of the array
+ * - the array is not homogenous: the array contains mixed types
+ * @param {unknown[]} data - The array to validate
+ * @param {number[]} dimensions - The shape of the array
* @returns The primitive type of the array's elements
* @throws Error if the validation fails
*/
@@ -158,9 +158,9 @@ function validateArray(data: unknown[], dimensions: number[]): ArrayPrimitive {
/**
* Fetches JSON data from a URL.
* @template T - The expected type of the JSON response
- * @param url - The URL to fetch from
- * @param agent - HTTP agent to be used for the request
- * @param timeout - Request timeout, query will be aborted if not finished in time
+ * @param {string} url - The URL to fetch from
+ * @param {Agent} agent - HTTP agent to be used for the request
+ * @param {number} timeout - Request timeout, query will be aborted if not finished in time
* @returns Promise resolving to the parsed JSON data
* @throws Error if the request fails or returns a non-OK status
*/
diff --git a/src/validation.ts b/src/validation.ts
index 7ebea0b..e358d35 100644
--- a/src/validation.ts
+++ b/src/validation.ts
@@ -1,9 +1,9 @@
/**
- * Validates a table name.
- * Throws an error if table name is invalid.
+ * Validates a table name.
*
* @param {string} name - The table name to validate.
* @param {number} maxNameLength - The maximum length of table names.
+ * @throws Error if table name is invalid.
*/
function validateTableName(name: string, maxNameLength: number) {
const len = name.length;
@@ -65,11 +65,11 @@ function validateTableName(name: string, maxNameLength: number) {
}
/**
- * Validates a column name.
- * Throws an error if column name is invalid.
+ * Validates a column name.
*
* @param {string} name - The column name to validate.
* @param {number} maxNameLength - The maximum length of column names.
+ * @throws Error if column name is invalid.
*/
function validateColumnName(name: string, maxNameLength: number) {
const len = name.length;
From 1f958d775c36c5099c0ffe9a0e4844c99d585c74 Mon Sep 17 00:00:00 2001
From: glasstiger
Date: Wed, 6 Aug 2025 23:30:27 +0100
Subject: [PATCH 34/38] js doc update
---
docs/HttpTransport.html | 2 +-
docs/HttpTransportBase.html | 2 +-
docs/Sender.html | 18 +++++++++---------
docs/SenderBufferBase.html | 2 +-
docs/SenderBufferV1.html | 2 +-
docs/SenderBufferV2.html | 2 +-
docs/SenderOptions.html | 4 ++--
docs/TcpTransport.html | 2 +-
docs/UndiciTransport.html | 2 +-
docs/global.html | 2 +-
docs/index.html | 2 +-
docs/index.js.html | 20 ++++++++++----------
src/options.ts | 2 +-
src/sender.ts | 16 ++++++++--------
14 files changed, 39 insertions(+), 39 deletions(-)
diff --git a/docs/HttpTransport.html b/docs/HttpTransport.html
index 8b3331b..ea78da1 100644
--- a/docs/HttpTransport.html
+++ b/docs/HttpTransport.html
@@ -436,7 +436,7 @@
HTTP Transport Implementation:
By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling.
-For compatibility or specific requirements, you can enable the legacy HTTP transport using Node.js built-in modules
-by setting legacy_http=on in the configuration string. The legacy transport provides the same functionality
+For compatibility or specific requirements, you can enable the standard HTTP transport using Node.js built-in modules
+by setting stdlib_http=on in the configuration string. The standard HTTP transport provides the same functionality
but uses Node.js http/https modules instead of Undici.
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
-Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles.
+Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
TCP/TCPS connections default to version 1.
protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
- * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles.
+ * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
* When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
* TCP/TCPS connections default to version 1.
*
HTTP (http://): Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes.
+ *
HTTP: Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes.
* Recommended for most use cases due to superior error handling and debugging capabilities. Uses Undici library by default for high performance.
- *
HTTPS (https://): Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication.
+ *
HTTPS: Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication.
* Supports certificate validation and custom CA certificates.
TCPS with authentication: Sender.fromConfig("tcps::addr=localhost:9009;username=user;token=private_key")
*
*
*
* HTTP Transport Implementation:
* By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling.
- * For compatibility or specific requirements, you can enable the legacy HTTP transport using Node.js built-in modules
- * by setting legacy_http=on in the configuration string. The legacy transport provides the same functionality
+ * For compatibility or specific requirements, you can enable the standard HTTP transport using Node.js built-in modules
+ * by setting stdlib_http=on in the configuration string. The standard HTTP transport provides the same functionality
* but uses Node.js http/https modules instead of Undici.
*
import { Sender } from "@questdb/nodejs-client";
async function run() {
- // create a sender with protocol version 2 to support arrays
+ // create a sender with protocol version 2
const sender = await Sender.fromConfig('http::addr=localhost:9000;protocol_version=2');
// order book snapshots
@@ -296,7 +296,7 @@