From cea4280c3665ff345d9a4081883b8d20ea9cd321 Mon Sep 17 00:00:00 2001 From: Raphael DALMON Date: Wed, 22 Oct 2025 17:53:16 +0200 Subject: [PATCH 1/5] feat: support decimal --- README.md | 60 ++++++++ questdb-client-test | 2 +- src/buffer/base.ts | 36 ++++- src/buffer/bufferv3.ts | 123 ++++++++++++++++ src/buffer/index.ts | 33 +++++ src/index.ts | 1 + src/options.ts | 7 +- src/sender.ts | 36 +++++ src/utils.ts | 39 +++++ src/validation.ts | 21 ++- test/options.test.ts | 47 ++++-- test/sender.buffer.test.ts | 289 +++++++++++++++++++++++++++++++++++-- test/util/mockhttp.ts | 2 +- test/utils.decimal.test.ts | 24 +++ 14 files changed, 687 insertions(+), 33 deletions(-) create mode 100644 src/buffer/bufferv3.ts create mode 100644 test/utils.decimal.test.ts diff --git a/README.md b/README.md index 7e6fb85..2c5a2c3 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,66 @@ async function run() { run().then(console.log).catch(console.error); ``` +### DECIMAL columns (ILP v3) + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +async function runDecimals() { + const sender = await Sender.fromConfig( + "tcp::addr=127.0.0.1:9009;protocol_version=3", + ); + + await sender + .table("fx") + // textual ILP form keeps the literal and its exact scale + .decimalColumnText("mid", "1.234500") + .atNow(); + + await sender.flush(); + await sender.close(); +} + +runDecimals().catch(console.error); +// Resulting ILP line: fx mid=1.234500d +``` + +For binary ILP v3, decimals are transmitted as an unscaled integer with an explicit scale: + +```text +fx mid==\x17\x06\x03\x12\xD6\x44 +``` + +- `0x17` is the decimal entity type. +- `0x06` is the scale (six decimal places). +- `0x03` is the payload length. +- `0x12 0xD6 0x44` is the two's complement big-endian encoding of the unscaled value `1234500`. + +Any client that emits the same scale (`6`) and unscaled integer (`1234500n`) will store the value `1.234500` exactly, including its trailing zeros. + +A shorter payload works the same way. For example, the decimal `123.456` has an unscaled integer of `123456` and a scale of `3`, so the binary payload is: + +```text +\x17 0x03 0x03 0x01 0xE2 0x40 +``` + +- `0x03` is the scale. +- `0x03` is the length of the integer payload. +- `0x01 0xE2 0x40` is the big-endian byte array for `123456`. + +In JavaScript you can generate the payload bytes like this: + +```typescript +import { bigintToTwosComplementBytes } from "@questdb/nodejs-client"; + +function decimalPayload(unscaled: bigint, scale: number): number[] { + const magnitude = bigintToTwosComplementBytes(unscaled); + return [0x17, scale, magnitude.length, ...magnitude]; +} + +decimalPayload(123456n, 3); // [0x17, 0x03, 0x03, 0x01, 0xE2, 0x40] → 123.456 +``` + ### Authentication and secure connection #### Username and password authentication with HTTP transport diff --git a/questdb-client-test b/questdb-client-test index 47be4f5..1aaa3f9 160000 --- a/questdb-client-test +++ b/questdb-client-test @@ -1 +1 @@ -Subproject commit 47be4f59b1ca54f3e285e2ae5bf307003544a867 +Subproject commit 1aaa3f96ab06c6bef7d1b08400c418ef87562e56 diff --git a/src/buffer/base.ts b/src/buffer/base.ts index 2e61ddb..b34c289 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -438,7 +438,7 @@ abstract class SenderBufferBase implements SenderBuffer { /** * @ignore - * Writes a 32-bit integer to the buffer in little-endian format. + * Writes a 32-bit integer to the buffer in big-endian format. * @param data - Integer value to write */ protected writeInt(data: number) { @@ -447,7 +447,7 @@ abstract class SenderBufferBase implements SenderBuffer { /** * @ignore - * Writes a double-precision float to the buffer in little-endian format. + * Writes a double-precision float to the buffer in big-endian format. * @param data - Double value to write */ protected writeDouble(data: number) { @@ -490,6 +490,38 @@ abstract class SenderBufferBase implements SenderBuffer { } } } + + /** + * Writes a decimal value into the buffer. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} value - Column value, accepts only number/string values. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnText(name: string, value: string | number): SenderBuffer { + throw new Error("Decimals are not supported in protocol v1/v2"); + } + + /** + * Writes a decimal value into the buffer in text format. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} unscaled - The unscaled value of the decimal in two's + * complement representation and big-endian byte order. + * @param {number} scale - The scale of the decimal value. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnUnscaled( + name: string, + unscaled: Int8Array | bigint, + scale: number, + ): SenderBuffer { + throw new Error("Decimals are not supported in protocol v1/v2"); + } } export { SenderBufferBase }; diff --git a/src/buffer/bufferv3.ts b/src/buffer/bufferv3.ts new file mode 100644 index 0000000..dc4360e --- /dev/null +++ b/src/buffer/bufferv3.ts @@ -0,0 +1,123 @@ +// @ts-check +import { SenderOptions } from "../options"; +import { SenderBuffer } from "./index"; +import { + ArrayPrimitive, + bigintToTwosComplementBytes, + getDimensions, + timestampToMicros, + TimestampUnit, + validateArray, +} from "../utils"; +import { SenderBufferV2 } from "./bufferv2"; +import { validateDecimalText } from "../validation"; + +// Entity type constants for protocol v3. +const ENTITY_TYPE_DECIMAL: number = 23; + +// ASCII code for equals sign used in binary protocol. +const EQUALS_SIGN: number = "=".charCodeAt(0); + +/** + * Buffer implementation for protocol version 3. + * + * Provides support for decimals. + */ +class SenderBufferV3 extends SenderBufferV2 { + /** + * Creates a new SenderBufferV2 instance. + * + * @param {SenderOptions} options - Sender configuration object. + * + * See SenderOptions documentation for detailed description of configuration options. + */ + constructor(options: SenderOptions) { + super(options); + } + + /** + * Writes a decimal value into the buffer using the text format. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} value - Column value, accepts only number/string values. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnText(name: string, value: string | number): SenderBuffer { + let str = ""; + if (typeof value === "string") { + validateDecimalText(value); + str = value; + } else if (typeof value === "number") { + str = value.toString(); + } else { + throw new TypeError(`Invalid decimal value type: ${typeof value}`); + } + this.writeColumn(name, str, () => { + this.checkCapacity([str], 1); + this.write(str); + this.write("d"); + }); + return this; + } + + /** + * Writes a decimal value into the buffer using the binary format. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} unscaled - The unscaled value of the decimal in two's + * complement representation and big-endian byte order. + * An empty array represents the NULL value. + * @param {number} scale - The scale of the decimal value. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnUnscaled( + name: string, + unscaled: Int8Array | bigint, + scale: number, + ): SenderBuffer { + if (scale < 0 || scale > 76) { + throw new RangeError("Scale must be between 0 and 76"); + } + let arr: number[]; + if (typeof unscaled === "bigint") { + arr = bigintToTwosComplementBytes(unscaled); + } else if (unscaled instanceof Int8Array) { + arr = Array.from(unscaled); + } else { + throw new TypeError( + `Invalid unscaled value type: ${typeof unscaled}, expected Int8Array or bigint`, + ); + } + if (arr.length > 127) { + throw new RangeError( + "Unscaled value length must be between 0 and 127 bytes", + ); + } + this.writeColumn(name, unscaled, () => { + this.checkCapacity([], 4 + arr.length); + this.writeByte(EQUALS_SIGN); + this.writeByte(ENTITY_TYPE_DECIMAL); + this.writeByte(scale); + this.writeByte(arr.length); + for (let i = 0; i < arr.length; i++) { + let byte = arr[i]; + if (byte > 255 || byte < -128) { + throw new RangeError( + `Unscaled value contains invalid byte [index=${i}, value=${byte}]`, + ); + } + if (byte > 127) { + byte -= 256; + } + this.writeByte(byte); + } + }); + return this; + } +} + +export { SenderBufferV3 }; diff --git a/src/buffer/index.ts b/src/buffer/index.ts index d4ddc71..6225b8c 100644 --- a/src/buffer/index.ts +++ b/src/buffer/index.ts @@ -6,10 +6,12 @@ import { PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V2, PROTOCOL_VERSION_AUTO, + PROTOCOL_VERSION_V3, } from "../options"; import { TimestampUnit } from "../utils"; import { SenderBufferV1 } from "./bufferv1"; import { SenderBufferV2 } from "./bufferv2"; +import { SenderBufferV3 } from "./bufferv3"; // Default initial buffer size in bytes (64 KB). const DEFAULT_BUFFER_SIZE = 65536; // 64 KB @@ -26,6 +28,8 @@ const DEFAULT_MAX_BUFFER_SIZE = 104857600; // 100 MB */ function createBuffer(options: SenderOptions): SenderBuffer { switch (options.protocol_version) { + case PROTOCOL_VERSION_V3: + return new SenderBufferV3(options); case PROTOCOL_VERSION_V2: return new SenderBufferV2(options); case PROTOCOL_VERSION_V1: @@ -153,6 +157,35 @@ interface SenderBuffer { unit: TimestampUnit, ): SenderBuffer; + /** + * Writes a decimal value into the buffer using the text format. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} value - Column value, accepts only number/string values. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnText(name: string, value: string | number): SenderBuffer; + + /** + * Writes a decimal value into the buffer using the binary format. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} unscaled - The unscaled value of the decimal in two's + * complement representation and big-endian byte order. + * An empty array represents the NULL value. + * @param {number} scale - The scale of the decimal value. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnUnscaled( + name: string, + unscaled: Int8Array | bigint, + scale: number, + ): SenderBuffer; + /** * Closes the row after writing the designated timestamp into the buffer. * @param timestamp - Designated epoch timestamp, accepts numbers or BigInts. diff --git a/src/index.ts b/src/index.ts index d6626ae..bc8e514 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,3 +17,4 @@ export { TcpTransport } from "./transport/tcp"; export { HttpTransport } from "./transport/http/stdlib"; export { UndiciTransport } from "./transport/http/undici"; export type { Logger } from "./logging"; +export { bigintToTwosComplementBytes } from "./utils"; diff --git a/src/options.ts b/src/options.ts index 8732c01..f09cc6c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -23,6 +23,7 @@ const UNSAFE_OFF = "unsafe_off"; const PROTOCOL_VERSION_AUTO = "auto"; const PROTOCOL_VERSION_V1 = "1"; const PROTOCOL_VERSION_V2 = "2"; +const PROTOCOL_VERSION_V3 = "3"; const LINE_PROTO_SUPPORT_VERSION = "line.proto.support.versions"; @@ -258,6 +259,8 @@ class SenderOptions { if (supportedVersions.length === 0) { options.protocol_version = PROTOCOL_VERSION_V1; + } else if (supportedVersions.includes(PROTOCOL_VERSION_V3)) { + options.protocol_version = PROTOCOL_VERSION_V3; } else if (supportedVersions.includes(PROTOCOL_VERSION_V2)) { options.protocol_version = PROTOCOL_VERSION_V2; } else if (supportedVersions.includes(PROTOCOL_VERSION_V1)) { @@ -488,10 +491,11 @@ function parseProtocolVersion(options: SenderOptions) { break; case PROTOCOL_VERSION_V1: case PROTOCOL_VERSION_V2: + case PROTOCOL_VERSION_V3: break; default: throw new Error( - `Invalid protocol version: '${protocol_version}', accepted values: 'auto', '1', '2'`, + `Invalid protocol version: '${protocol_version}', accepted values: 'auto', '1', '2', '3'`, ); } return; @@ -628,4 +632,5 @@ export { PROTOCOL_VERSION_AUTO, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V2, + PROTOCOL_VERSION_V3, }; diff --git a/src/sender.ts b/src/sender.ts index 275a018..a5c6299 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -326,6 +326,42 @@ class Sender { return this; } + /** + * Writes a decimal column into the buffer of the sender in the text format. + * + * @param {string} name - Column name + * @param {unknown[]} value - Column value to write, accepts only number/string values. + * @returns {Sender} Returns with a reference to this sender. + * @throws Error if decimals are not supported by the buffer implementation, or decimal validation fails: + * - value is not a number/string + * - or the string contains invalid characters + */ + decimalColumnText(name: string, value: string | number): Sender { + this.buffer.decimalColumnText(name, value); + return this; + } + + /** + * Writes a decimal value into the buffer using the binary format. + * + * Use it to insert into DECIMAL database columns. + * + * @param {string} name - Column name. + * @param {number} unscaled - The unscaled value of the decimal in two's + * complement representation and big-endian byte order. + * An empty array represents the NULL value. + * @param {number} scale - The scale of the decimal value. + * @returns {Sender} Returns with a reference to this buffer. + */ + decimalColumnUnscaled( + name: string, + unscaled: Int8Array | bigint, + scale: number, + ): Sender { + this.buffer.decimalColumnUnscaled(name, unscaled, scale); + return this; + } + /** * Closes the row after writing the designated timestamp into the buffer of the sender. * diff --git a/src/utils.ts b/src/utils.ts index 968734e..1d598fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -193,6 +193,44 @@ async function fetchJson( return (await response.json()) as T; } +/** + * Converts a bigint into a two's complement big-endian byte array. + * Produces the minimal-width representation that preserves the sign. + * @param {bigint} value - The value to serialise + * @returns {number[]} Byte array in big-endian order + */ +function bigintToTwosComplementBytes(value: bigint): number[] { + if (value === 0n) { + return [0]; + } + + const bytes: number[] = []; + const byteMask = 0xffn; + + if (value > 0n) { + let tmp = value; + while (tmp > 0n) { + bytes.unshift(Number(tmp & byteMask)); + tmp >>= 8n; + } + if (bytes[0] & 0x80) { + bytes.unshift(0); + } + return bytes; + } + + let tmp = value; + while (tmp < -1n) { + bytes.unshift(Number(tmp & byteMask)); + tmp >>= 8n; + } + bytes.unshift(Number(tmp & byteMask)); + if (!(bytes[0] & 0x80)) { + bytes.unshift(0xff); + } + return bytes; +} + export { isBoolean, isInteger, @@ -203,4 +241,5 @@ export { getDimensions, validateArray, ArrayPrimitive, + bigintToTwosComplementBytes, }; diff --git a/src/validation.ts b/src/validation.ts index 85239ae..d3ddd9b 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -119,4 +119,23 @@ function validateColumnName(name: string, maxNameLength: number): void { } } -export { validateTableName, validateColumnName }; +/** + * Validates a decimal text. + * + * This is a partial validation to catch obvious errors early. + * We only accept numeric digits, signs, decimal point (.), exponent (e, E), and NaN/Infinity. + * + * @param {string} value - The decimal text to validate. + * @throws Error if decimal text is invalid. + */ +function validateDecimalText(value: string): void { + if (value.length === 0) { + throw new Error("Decimal text cannot be empty"); + } + const decimalRegex = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$|^[-+]?Infinity$|^NaN$/; + if (!decimalRegex.test(value)) { + throw new Error(`Invalid decimal text: ${value}`); + } +} + +export { validateTableName, validateColumnName, validateDecimalText }; diff --git a/test/options.test.ts b/test/options.test.ts index fcbbab3..125df3c 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -236,9 +236,9 @@ describe("Configuration string parser suite", function () { // invalid protocol version await expect( async () => - await SenderOptions.fromConfig("tcp::addr=hostname;protocol_version=3"), + await SenderOptions.fromConfig("tcp::addr=hostname;protocol_version=4"), ).rejects.toThrow( - "Invalid protocol version: '3', accepted values: 'auto', '1', '2'", + "Invalid protocol version: '4', accepted values: 'auto', '1', '2', '3'", ); await expect( async () => @@ -246,7 +246,7 @@ describe("Configuration string parser suite", function () { "http::addr=hostname;protocol_version=0", ), ).rejects.toThrow( - "Invalid protocol version: '0', accepted values: 'auto', '1', '2'", + "Invalid protocol version: '0', accepted values: 'auto', '1', '2', '3'", ); await expect( async () => @@ -254,7 +254,7 @@ describe("Configuration string parser suite", function () { "http::addr=hostname;protocol_version=-1", ), ).rejects.toThrow( - "Invalid protocol version: '-1', accepted values: 'auto', '1', '2'", + "Invalid protocol version: '-1', accepted values: 'auto', '1', '2', '3'", ); await expect( async () => @@ -262,12 +262,12 @@ describe("Configuration string parser suite", function () { "https::addr=hostname;protocol_version=automatic", ), ).rejects.toThrow( - "Invalid protocol version: 'automatic', accepted values: 'auto', '1', '2'", + "Invalid protocol version: 'automatic', accepted values: 'auto', '1', '2', '3'", ); let options: SenderOptions; - // defaults with supported versions: 1,2 + // defaults with supported versions: 1,2,3 mockHttp.reset(); mockHttps.reset(); options = await SenderOptions.fromConfig("tcp::addr=localhost"); @@ -277,11 +277,11 @@ describe("Configuration string parser suite", function () { options = await SenderOptions.fromConfig( `http::addr=localhost:${MOCK_HTTP_PORT}`, ); - expect(options.protocol_version).toBe("2"); + expect(options.protocol_version).toBe("3"); options = await SenderOptions.fromConfig( `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`, ); - expect(options.protocol_version).toBe("2"); + expect(options.protocol_version).toBe("3"); // defaults with supported versions: 1 const only1 = { @@ -328,7 +328,7 @@ describe("Configuration string parser suite", function () { // defaults with no match with supported versions const no1and2 = { settings: { - config: { "line.proto.support.versions": [3, 5] }, + config: { "line.proto.support.versions": [4, 5] }, }, }; mockHttp.reset(no1and2); @@ -343,7 +343,7 @@ describe("Configuration string parser suite", function () { `http::addr=localhost:${MOCK_HTTP_PORT};tls_verify=unsafe_off`, ), ).rejects.toThrow( - "Unsupported protocol versions received from server: 3,5", + "Unsupported protocol versions received from server: 4,5", ); await expect( async () => @@ -351,7 +351,7 @@ describe("Configuration string parser suite", function () { `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off`, ), ).rejects.toThrow( - "Unsupported protocol versions received from server: 3,5", + "Unsupported protocol versions received from server: 4,5", ); // auto, 1, 2 with each protocol (tcp, tcps, http, https), supported versions: 1,2 @@ -365,6 +365,10 @@ describe("Configuration string parser suite", function () { "tcp::addr=localhost;protocol_version=2", ); expect(options.protocol_version).toBe("2"); + options = await SenderOptions.fromConfig( + "tcp::addr=localhost;protocol_version=3", + ); + expect(options.protocol_version).toBe("3"); options = await SenderOptions.fromConfig( "tcp::addr=localhost;protocol_version=auto", ); @@ -378,6 +382,10 @@ describe("Configuration string parser suite", function () { "tcps::addr=localhost;protocol_version=2", ); expect(options.protocol_version).toBe("2"); + options = await SenderOptions.fromConfig( + "tcps::addr=localhost;protocol_version=3", + ); + expect(options.protocol_version).toBe("3"); options = await SenderOptions.fromConfig( "tcps::addr=localhost;protocol_version=auto", ); @@ -391,10 +399,14 @@ describe("Configuration string parser suite", function () { `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=3`, + ); + expect(options.protocol_version).toBe("3"); options = await SenderOptions.fromConfig( `http::addr=localhost:${MOCK_HTTP_PORT};protocol_version=auto`, ); - expect(options.protocol_version).toBe("2"); + expect(options.protocol_version).toBe("3"); options = await SenderOptions.fromConfig( `https::addr=localhost:${MOCK_HTTPS_PORT};protocol_version=1`, @@ -404,10 +416,14 @@ describe("Configuration string parser suite", function () { `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=3`, + ); + expect(options.protocol_version).toBe("3"); options = await SenderOptions.fromConfig( `https::addr=localhost:${MOCK_HTTPS_PORT};tls_verify=unsafe_off;protocol_version=auto`, ); - expect(options.protocol_version).toBe("2"); + expect(options.protocol_version).toBe("3"); }); it("fails if port is not a positive integer", async function () { @@ -666,10 +682,11 @@ describe("Configuration string parser suite", function () { async () => await SenderOptions.fromConfig(""), ).rejects.toThrow("Configuration string is missing"); await expect( - async () => await SenderOptions.fromConfig(null), + async () => await SenderOptions.fromConfig(null as unknown as string), ).rejects.toThrow("Configuration string is missing"); await expect( - async () => await SenderOptions.fromConfig(undefined), + async () => + await SenderOptions.fromConfig(undefined as unknown as string), ).rejects.toThrow("Configuration string is missing"); }); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index f5f2406..e7ed982 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -3,6 +3,29 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { Sender, SenderOptions } from "../src"; +import { PROTOCOL_VERSION_V3 } from "../src/options"; + +type Column = { name: string } & ( + | { type: "STRING"; value: string } + | { type: "LONG"; value: number } + | { type: "DOUBLE"; value: number } + | { type: "BOOLEAN"; value: boolean } + | { type: "TIMESTAMP"; value: number | bigint } + | { type: "DECIMAL"; value: string } +); + +interface TestCase { + testName: string; + table: string; + symbols: Array<{ name: string; value: string }>; + columns: Array; + result: { + status: string; + line?: string; + anyLines?: Array; + binaryBase64?: string; + }; +} describe("Client interop test suite", function () { it("runs client tests as per json test config", async function () { @@ -10,7 +33,7 @@ describe("Client interop test suite", function () { readFileSync( "./questdb-client-test/ilp-client-interop-test.json", ).toString(), - ); + ) as TestCase[]; for (const testCase of testCases) { console.info(`test name: ${testCase.testName}`); @@ -21,10 +44,11 @@ describe("Client interop test suite", function () { host: "host", auto_flush: false, init_buf_size: 1024, + protocol_version: PROTOCOL_VERSION_V3, }), ); - let errorMessage: string; + let errorMessage: string | undefined = undefined; try { sender.table(testCase.table); for (const symbol of testCase.symbols) { @@ -47,6 +71,10 @@ describe("Client interop test suite", function () { case "TIMESTAMP": sender.timestampColumn(column.name, column.value); break; + case "DECIMAL": + const [unscaled, scale] = parseDecimal(column.value); + sender.decimalColumnUnscaled(column.name, unscaled, scale); + break; default: errorMessage = "Unsupported column type"; } @@ -60,16 +88,20 @@ describe("Client interop test suite", function () { // error is expected, continue to next test case continue; } - errorMessage = `Unexpected error: ${e.message}`; + errorMessage = `Unexpected error: ${(e as Error).message}`; } if (!errorMessage) { const actualLine = bufferContent(sender); if (testCase.result.status === "SUCCESS") { - if (testCase.result.line) { - expect(actualLine).toBe(testCase.result.line + "\n"); - } else { + if (testCase.result.binaryBase64) { + const expectedBuffer = Buffer.from( + testCase.result.binaryBase64, + "base64", + ); + expect(buffer(sender)).toEqual(expectedBuffer); + } else if (testCase.result.anyLines) { let foundMatch = false; for (const expectedLine of testCase.result.anyLines) { if (actualLine === expectedLine + "\n") { @@ -80,6 +112,10 @@ describe("Client interop test suite", function () { if (!foundMatch) { errorMessage = `Line is not matching any of the expected results: ${actualLine}`; } + } else if (testCase.result.line) { + expect(actualLine).toBe(testCase.result.line + "\n"); + } else { + errorMessage = "No expected result line provided"; } } else { errorMessage = `Expected error missing, buffer's content: ${actualLine}`; @@ -408,8 +444,14 @@ describe("Sender message builder test suite (anything not covered in client inte host: "host", init_buf_size: 1024, }); - await sender.table("tableName").arrayColumn("arrayCol", undefined).atNow(); - await sender.table("tableName").arrayColumn("arrayCol", null).atNow(); + await sender + .table("tableName") + .arrayColumn("arrayCol", undefined as unknown as unknown[]) + .atNow(); + await sender + .table("tableName") + .arrayColumn("arrayCol", null as unknown as unknown[]) + .atNow(); expect(bufferContentHex(sender)).toBe( toHex("tableName arrayCol==") + " 0e 21 " + @@ -973,7 +1015,7 @@ describe("Sender message builder test suite (anything not covered in client inte .table("tableName") .symbol("name", "value") .at(23232322323.05); - } catch (e) { + } catch (e: Error | any) { expect(e.message).toEqual( "Designated timestamp must be an integer or BigInt, received 23232322323.05", ); @@ -991,7 +1033,7 @@ describe("Sender message builder test suite (anything not covered in client inte try { // @ts-expect-error - Invalid options await sender.table("tableName").symbol("name", "value").at("invalid_dts"); - } catch (e) { + } catch (e: Error | any) { expect(e.message).toEqual( "Designated timestamp must be an integer or BigInt, received invalid_dts", ); @@ -1008,7 +1050,7 @@ describe("Sender message builder test suite (anything not covered in client inte }); try { await sender.table("tableName").at(12345678n, "ns"); - } catch (e) { + } catch (e: Error | any) { expect(e.message).toEqual( "The row must have a symbol or column set before it is closed", ); @@ -1107,7 +1149,7 @@ describe("Sender message builder test suite (anything not covered in client inte .intColumn("intField", 125) .stringColumn("strField", "test") .atNow(); - } catch (err) { + } catch (err: Error | any) { expect(err.message).toBe( "Max buffer size is 64 bytes, requested buffer size: 96", ); @@ -1148,6 +1190,198 @@ describe("Sender message builder test suite (anything not covered in client inte ); await sender.close(); }); + + it("writes decimal columns in text format with protocol v3", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender.table("fx").decimalColumnText("mid", "1.234500").atNow(); + expect(bufferContent(sender)).toBe("fx mid=1.234500d\n"); + await sender.close(); + }); + + it("writes decimal columns in binary format with bigint input small", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender.table("fx").decimalColumnUnscaled("mid", 12345n, 2).atNow(); + expect(bufferContentHex(sender)).toBe( + toHex("fx mid==") + " 17 02 02 30 39 " + toHex("\n"), + ); + await sender.close(); + }); + + it("writes decimal columns in binary format with bigint input", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender.table("fx").decimalColumnUnscaled("mid", 1234500n, 6).atNow(); + expect(bufferContentHex(sender)).toBe( + toHex("fx mid==") + " 17 06 03 12 d6 44 " + toHex("\n"), + ); + await sender.close(); + }); + + it("writes decimal columns in binary format with Int8Array input", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender + .table("fx") + .decimalColumnUnscaled("mid", new Int8Array([0xff, 0xf6]), 2) + .atNow(); + expect(bufferContentHex(sender)).toBe( + toHex("fx mid==") + " 17 02 02 ff f6 " + toHex("\n"), + ); + await sender.close(); + }); + + it("accepts numeric inputs for decimalColumnText", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender.table("fx").decimalColumnText("mid", -42.5).atNow(); + expect(bufferContent(sender)).toBe("fx mid=-42.5d\n"); + await sender.close(); + }); + + it("throws on invalid decimal text literal", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + expect(() => sender.table("fx").decimalColumnText("mid", "1.2.3")).toThrow( + "Invalid decimal text: 1.2.3", + ); + sender.reset(); + await sender.close(); + }); + + it("throws on unsupported decimal text value type", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + expect(() => + sender.table("fx").decimalColumnText("mid", true as unknown as number), + ).toThrow("Invalid decimal value type: boolean"); + sender.reset(); + await sender.close(); + }); + + it("encodes positive bigint decimals that require sign extension", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender.table("fx").decimalColumnUnscaled("mid", 255n, 1).atNow(); + expect(bufferContentHex(sender)).toBe( + toHex("fx mid==") + " 17 01 02 00 ff " + toHex("\n"), + ); + await sender.close(); + }); + + it("encodes negative bigint decimals with the proper two's complement payload", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender.table("fx").decimalColumnUnscaled("mid", -10n, 2).atNow(); + expect(bufferContentHex(sender)).toBe( + toHex("fx mid==") + " 17 02 02 ff f6 " + toHex("\n"), + ); + await sender.close(); + }); + + it("encodes null decimals when unscaled payload is empty", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender + .table("fx") + .decimalColumnUnscaled("mid", new Int8Array(0), 0) + .atNow(); + expect(bufferContentHex(sender)).toBe( + toHex("fx mid==") + " 17 00 00 " + toHex("\n"), + ); + await sender.close(); + }); + + it("throws when decimal scale is outside the accepted range", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + expect(() => + sender.table("fx").decimalColumnUnscaled("mid", 1n, -1), + ).toThrow("Scale must be between 0 and 76"); + sender.reset(); + expect(() => + sender.table("fx").decimalColumnUnscaled("mid", 1n, 77), + ).toThrow("Scale must be between 0 and 76"); + sender.reset(); + await sender.close(); + }); + + it("throws when unscaled payload is not Int8Array or bigint", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + expect(() => + sender + .table("fx") + .decimalColumnUnscaled("mid", "oops" as unknown as Int8Array, 1), + ).toThrow( + "Invalid unscaled value type: string, expected Int8Array or bigint", + ); + sender.reset(); + await sender.close(); + }); + + it("throws when unscaled payload exceeds maximum length", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + expect(() => + sender.table("fx").decimalColumnUnscaled("mid", new Int8Array(128), 0), + ).toThrow("Unscaled value length must be between 0 and 127 bytes"); + sender.reset(); + await sender.close(); + }); }); function bufferContent(sender: Sender) { @@ -1170,6 +1404,11 @@ function toHexString(buffer: Buffer) { .join(" "); } +function buffer(sender: Sender) { + // @ts-expect-error - Accessing private field + return sender.buffer.toBufferView(); +} + function bufferSize(sender: Sender) { // @ts-expect-error - Accessing private field return sender.buffer.bufferSize; @@ -1179,3 +1418,29 @@ function bufferPosition(sender: Sender) { // @ts-expect-error - Accessing private field return sender.buffer.position; } + +// parseDecimal quick and dirty parser for a decimal value from its string representation +function parseDecimal(s: string): [bigint, number] { + // Remove whitespace + s = s.trim(); + + // Check for empty string + if (s === "") { + throw new Error("invalid decimal64: empty string"); + } + + // Find the decimal point and remove it + const pointIndex = s.indexOf("."); + if (pointIndex !== -1) { + s = s.replace(/\./g, ""); + } + + // Parse the integer part + const unscaled = BigInt(s); + let scale = 0; + if (pointIndex !== -1) { + scale = s.length - pointIndex; + } + + return [unscaled, scale]; +} diff --git a/test/util/mockhttp.ts b/test/util/mockhttp.ts index 35b72a0..296ba6b 100644 --- a/test/util/mockhttp.ts +++ b/test/util/mockhttp.ts @@ -24,7 +24,7 @@ class MockHttp { reset(mockConfig: MockConfig = {}) { if (!mockConfig.settings) { mockConfig.settings = { - config: { "line.proto.support.versions": [1, 2] }, + config: { "line.proto.support.versions": [1, 2, 3] }, }; } diff --git a/test/utils.decimal.test.ts b/test/utils.decimal.test.ts new file mode 100644 index 0000000..bbf8b41 --- /dev/null +++ b/test/utils.decimal.test.ts @@ -0,0 +1,24 @@ +// @ts-check +import { describe, it, expect } from "vitest"; +import { bigintToTwosComplementBytes } from "../src/utils"; + +describe("bigintToTwosComplementBytes", () => { + it("encodes zero as a single zero byte", () => { + expect(bigintToTwosComplementBytes(0n)).toEqual([0x00]); + }); + + it("encodes positive values without unnecessary sign-extension", () => { + expect(bigintToTwosComplementBytes(1n)).toEqual([0x01]); + expect(bigintToTwosComplementBytes(123456n)).toEqual([0x01, 0xe2, 0x40]); + }); + + it("adds a leading zero when the positive sign bit would be set", () => { + expect(bigintToTwosComplementBytes(255n)).toEqual([0x00, 0xff]); + }); + + it("encodes negative values with two's complement sign extension", () => { + expect(bigintToTwosComplementBytes(-1n)).toEqual([0xff]); + expect(bigintToTwosComplementBytes(-10n)).toEqual([0xff, 0xf6]); + expect(bigintToTwosComplementBytes(-256n)).toEqual([0xff, 0x00]); + }); +}); From c68444019d8c09c6d571dac7353cd9bf1f20f11c Mon Sep 17 00:00:00 2001 From: Raphael DALMON Date: Wed, 22 Oct 2025 17:59:27 +0200 Subject: [PATCH 2/5] feat: add examples for DECIMAL data type usage in README --- README.md | 114 ++++++++++++++++++++++++++---------------------------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 2c5a2c3..9532b18 100644 --- a/README.md +++ b/README.md @@ -65,66 +65,6 @@ async function run() { run().then(console.log).catch(console.error); ``` -### DECIMAL columns (ILP v3) - -```typescript -import { Sender } from "@questdb/nodejs-client"; - -async function runDecimals() { - const sender = await Sender.fromConfig( - "tcp::addr=127.0.0.1:9009;protocol_version=3", - ); - - await sender - .table("fx") - // textual ILP form keeps the literal and its exact scale - .decimalColumnText("mid", "1.234500") - .atNow(); - - await sender.flush(); - await sender.close(); -} - -runDecimals().catch(console.error); -// Resulting ILP line: fx mid=1.234500d -``` - -For binary ILP v3, decimals are transmitted as an unscaled integer with an explicit scale: - -```text -fx mid==\x17\x06\x03\x12\xD6\x44 -``` - -- `0x17` is the decimal entity type. -- `0x06` is the scale (six decimal places). -- `0x03` is the payload length. -- `0x12 0xD6 0x44` is the two's complement big-endian encoding of the unscaled value `1234500`. - -Any client that emits the same scale (`6`) and unscaled integer (`1234500n`) will store the value `1.234500` exactly, including its trailing zeros. - -A shorter payload works the same way. For example, the decimal `123.456` has an unscaled integer of `123456` and a scale of `3`, so the binary payload is: - -```text -\x17 0x03 0x03 0x01 0xE2 0x40 -``` - -- `0x03` is the scale. -- `0x03` is the length of the integer payload. -- `0x01 0xE2 0x40` is the big-endian byte array for `123456`. - -In JavaScript you can generate the payload bytes like this: - -```typescript -import { bigintToTwosComplementBytes } from "@questdb/nodejs-client"; - -function decimalPayload(unscaled: bigint, scale: number): number[] { - const magnitude = bigintToTwosComplementBytes(unscaled); - return [0x17, scale, magnitude.length, ...magnitude]; -} - -decimalPayload(123456n, 3); // [0x17, 0x03, 0x03, 0x01, 0xE2, 0x40] → 123.456 -``` - ### Authentication and secure connection #### Username and password authentication with HTTP transport @@ -372,6 +312,60 @@ function rndInt(limit: number) { run().then(console.log).catch(console.error); ``` +### Decimal usage example + +Since v9.2.0, QuestDB supports the DECIMAL data type. +Decimals can be ingested with ILP protocol v3 using either textual or binary representation. + +#### Textual representation + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +async function runDecimals() { + const sender = await Sender.fromConfig( + "tcp::addr=127.0.0.1:9009;protocol_version=3", + ); + + await sender + .table("fx") + // textual ILP form keeps the literal and its exact scale + .decimalColumnText("mid", "1.234500") + .atNow(); + + await sender.flush(); + await sender.close(); +} + +runDecimals().catch(console.error); +// Resulting ILP line: fx mid=1.234500d +``` + +#### Binary representation + +It is recommended to use the binary representation for better ingestion performance and reduced payload size (for bigger decimals). + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +async function runDecimals() { + const sender = await Sender.fromConfig( + "tcp::addr=127.0.0.1:9009;protocol_version=3", + ); + + await sender + .table("fx") + // textual ILP form keeps the literal and its exact scale + .decimalColumnUnscaled("mid", 123456n, 3) // 123456 * 10^-3 = 123.456 + .atNow(); + + await sender.flush(); + await sender.close(); +} + +runDecimals().catch(console.error); +``` + ## Community If you need help, have additional questions or want to provide feedback, you From a1301213f8d8da45a1df3da8f0578b0dea18cae9 Mon Sep 17 00:00:00 2001 From: Raphael DALMON Date: Wed, 22 Oct 2025 18:10:31 +0200 Subject: [PATCH 3/5] feat: update decimal handling in SenderBuffer classes for protocol v3 support --- src/buffer/base.ts | 28 ++++++++++------------------ src/buffer/bufferv3.ts | 9 +-------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/src/buffer/base.ts b/src/buffer/base.ts index b34c289..c477002 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -491,37 +491,29 @@ abstract class SenderBufferBase implements SenderBuffer { } } + /* eslint-disable @typescript-eslint/no-unused-vars */ /** - * Writes a decimal value into the buffer. + * Decimal columns are only supported since protocol v3. * - * Use it to insert into DECIMAL database columns. - * - * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number/string values. - * @returns {Sender} Returns with a reference to this buffer. + * @throws Error indicating decimals are not supported in v1 and v2 */ - decimalColumnText(name: string, value: string | number): SenderBuffer { + decimalColumnText(_name: string, _value: string | number): SenderBuffer { throw new Error("Decimals are not supported in protocol v1/v2"); } /** - * Writes a decimal value into the buffer in text format. - * - * Use it to insert into DECIMAL database columns. + * Decimal columns are only supported since protocol v3. * - * @param {string} name - Column name. - * @param {number} unscaled - The unscaled value of the decimal in two's - * complement representation and big-endian byte order. - * @param {number} scale - The scale of the decimal value. - * @returns {Sender} Returns with a reference to this buffer. + * @throws Error indicating decimals are not supported in v1 and v2 */ decimalColumnUnscaled( - name: string, - unscaled: Int8Array | bigint, - scale: number, + _name: string, + _unscaled: Int8Array | bigint, + _scale: number, ): SenderBuffer { throw new Error("Decimals are not supported in protocol v1/v2"); } + /* eslint-enable @typescript-eslint/no-unused-vars */ } export { SenderBufferBase }; diff --git a/src/buffer/bufferv3.ts b/src/buffer/bufferv3.ts index dc4360e..9a56351 100644 --- a/src/buffer/bufferv3.ts +++ b/src/buffer/bufferv3.ts @@ -1,14 +1,7 @@ // @ts-check import { SenderOptions } from "../options"; import { SenderBuffer } from "./index"; -import { - ArrayPrimitive, - bigintToTwosComplementBytes, - getDimensions, - timestampToMicros, - TimestampUnit, - validateArray, -} from "../utils"; +import { bigintToTwosComplementBytes } from "../utils"; import { SenderBufferV2 } from "./bufferv2"; import { validateDecimalText } from "../validation"; From 88de8a7e5ff80752174731849729c67977ef5b89 Mon Sep 17 00:00:00 2001 From: Raphael DALMON Date: Wed, 29 Oct 2025 08:57:05 +0100 Subject: [PATCH 4/5] fix: update write methods to use little-endian format for integers and doubles --- src/buffer/base.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/buffer/base.ts b/src/buffer/base.ts index c477002..30d1d24 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -438,7 +438,7 @@ abstract class SenderBufferBase implements SenderBuffer { /** * @ignore - * Writes a 32-bit integer to the buffer in big-endian format. + * Writes a 32-bit integer to the buffer in little-endian format. * @param data - Integer value to write */ protected writeInt(data: number) { @@ -447,7 +447,7 @@ abstract class SenderBufferBase implements SenderBuffer { /** * @ignore - * Writes a double-precision float to the buffer in big-endian format. + * Writes a double-precision float to the buffer in little-endian format. * @param data - Double value to write */ protected writeDouble(data: number) { From 5c7d3d6fd5249414b43ca5ce647f3c001706fe64 Mon Sep 17 00:00:00 2001 From: Raphael DALMON Date: Mon, 3 Nov 2025 09:53:28 +0100 Subject: [PATCH 5/5] feat: enhance decimal handling in SenderBufferV3 and Sender classes to support null and undefined values --- src/buffer/bufferv3.ts | 13 +++++-- src/sender.ts | 7 +++- test/sender.buffer.test.ts | 75 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/buffer/bufferv3.ts b/src/buffer/bufferv3.ts index 9a56351..80a1599 100644 --- a/src/buffer/bufferv3.ts +++ b/src/buffer/bufferv3.ts @@ -18,7 +18,7 @@ const EQUALS_SIGN: number = "=".charCodeAt(0); */ class SenderBufferV3 extends SenderBufferV2 { /** - * Creates a new SenderBufferV2 instance. + * Creates a new SenderBufferV3 instance. * * @param {SenderOptions} options - Sender configuration object. * @@ -37,13 +37,18 @@ class SenderBufferV3 extends SenderBufferV2 { * @param {number} value - Column value, accepts only number/string values. * @returns {Sender} Returns with a reference to this buffer. */ - decimalColumnText(name: string, value: string | number): SenderBuffer { + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): SenderBuffer { let str = ""; if (typeof value === "string") { validateDecimalText(value); str = value; } else if (typeof value === "number") { str = value.toString(); + } else if (value === null || value === undefined) { + return this; } else { throw new TypeError(`Invalid decimal value type: ${typeof value}`); } @@ -69,7 +74,7 @@ class SenderBufferV3 extends SenderBufferV2 { */ decimalColumnUnscaled( name: string, - unscaled: Int8Array | bigint, + unscaled: Int8Array | bigint | null | undefined, scale: number, ): SenderBuffer { if (scale < 0 || scale > 76) { @@ -80,6 +85,8 @@ class SenderBufferV3 extends SenderBufferV2 { arr = bigintToTwosComplementBytes(unscaled); } else if (unscaled instanceof Int8Array) { arr = Array.from(unscaled); + } else if (unscaled === null || unscaled === undefined) { + return this; } else { throw new TypeError( `Invalid unscaled value type: ${typeof unscaled}, expected Int8Array or bigint`, diff --git a/src/sender.ts b/src/sender.ts index a5c6299..e0b3231 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -336,7 +336,10 @@ class Sender { * - value is not a number/string * - or the string contains invalid characters */ - decimalColumnText(name: string, value: string | number): Sender { + decimalColumnText( + name: string, + value: string | number | undefined | null, + ): Sender { this.buffer.decimalColumnText(name, value); return this; } @@ -355,7 +358,7 @@ class Sender { */ decimalColumnUnscaled( name: string, - unscaled: Int8Array | bigint, + unscaled: Int8Array | bigint | undefined | null, scale: number, ): Sender { this.buffer.decimalColumnUnscaled(name, unscaled, scale); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index e7ed982..e14ec29 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -1382,6 +1382,81 @@ describe("Sender message builder test suite (anything not covered in client inte sender.reset(); await sender.close(); }); + + it("doesn't send the decimal text column when undefined is passed as value", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + try { + await sender.table("fx").decimalColumnText("mid", undefined).atNow(); + } catch (e: Error | any) { + expect(e.message).toEqual( + "The row must have a symbol or column set before it is closed", + ); + } + sender.reset(); + await sender.close(); + }); + + it("doesn't send the decimal text column when null is passed as value", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + try { + await sender.table("fx").decimalColumnText("mid", null).atNow(); + } catch (e: Error | any) { + expect(e.message).toEqual( + "The row must have a symbol or column set before it is closed", + ); + } + sender.reset(); + await sender.close(); + }); + + it("doesn't send the decimal unscaled column when undefined is passed as value", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + try { + await sender + .table("fx") + .decimalColumnUnscaled("mid", undefined, 0) + .atNow(); + } catch (e: Error | any) { + expect(e.message).toEqual( + "The row must have a symbol or column set before it is closed", + ); + } + sender.reset(); + await sender.close(); + }); + + it("doesn't send the decimal unscaled column when null is passed as value", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + try { + await sender.table("fx").decimalColumnUnscaled("mid", null, 0).atNow(); + } catch (e: Error | any) { + expect(e.message).toEqual( + "The row must have a symbol or column set before it is closed", + ); + } + sender.reset(); + await sender.close(); + }); }); function bufferContent(sender: Sender) {