@bzync/nextsql 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client-encryption.js +86 -15
- package/nextsql.d.ts +58 -10
- package/nextsql.js +268 -6
- package/package.json +1 -1
package/client-encryption.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Node-specific NSCE1 implementation. The dependency injection keeps this
|
|
3
|
+
// Node-specific NSCE1/NSCE2 implementation. The dependency injection keeps this
|
|
4
4
|
// module on the driver's single public NextSQLError/Kind surface.
|
|
5
|
-
const { createCipheriv, createDecipheriv, randomBytes } = require('node:crypto');
|
|
5
|
+
const { createCipheriv, createDecipheriv, hkdfSync, randomBytes, timingSafeEqual } = require('node:crypto');
|
|
6
6
|
const fs = require('node:fs/promises');
|
|
7
7
|
|
|
8
8
|
module.exports = function createClientEncryption(deps) {
|
|
9
9
|
const { NextSQLError, Kind, encodeDecimalString, decodeDecimal, decodeNSJB, formatUUID, parseUUID } = deps;
|
|
10
10
|
const PREFIX = 'NSCE1.';
|
|
11
|
+
const DETERMINISTIC_PREFIX = 'NSCE2.';
|
|
11
12
|
const MAX_PLAINTEXT = 1 << 20;
|
|
12
13
|
const MAX_KEY_ID = 64;
|
|
13
14
|
const MAX_KEYS = 64;
|
|
14
15
|
const NONCE_SIZE = 12;
|
|
15
16
|
const TAG_SIZE = 16;
|
|
17
|
+
const DETERMINISTIC_KEY_INFO = Buffer.from('NextSQL NSCE2 AES-SIV v2', 'utf8');
|
|
16
18
|
|
|
17
19
|
const FieldType = Object.freeze({
|
|
18
20
|
UUID: Object.freeze({ kind: Kind.UUID, precision: 0, scale: 0, vecElem: 0 }),
|
|
@@ -334,18 +336,18 @@ module.exports = function createClientEncryption(deps) {
|
|
|
334
336
|
return Buffer.concat([Buffer.from([tag]), u32(raw.length), raw]);
|
|
335
337
|
}
|
|
336
338
|
|
|
337
|
-
function makeHeader(keyID, type, nonce) {
|
|
339
|
+
function makeHeader(keyID, type, nonce = null) {
|
|
338
340
|
const id = Buffer.from(keyID, 'ascii');
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
const out = Buffer.alloc(3 + id.length + 6 + (nonce ? NONCE_SIZE : 0));
|
|
342
|
+
out[0] = nonce ? 1 : 2; out[1] = nonce ? 1 : 2; out[2] = id.length; id.copy(out, 3);
|
|
341
343
|
const off = 3 + id.length;
|
|
342
344
|
out[off] = type.kind; out.writeUInt16LE(type.precision, off + 1); out.writeUInt16LE(type.scale, off + 3); out[off + 5] = type.vecElem;
|
|
343
|
-
|
|
345
|
+
if (nonce) nonce.copy(out, off + 6);
|
|
344
346
|
return out;
|
|
345
347
|
}
|
|
346
348
|
|
|
347
|
-
function aad(database, table, column, publicHeader) {
|
|
348
|
-
|
|
349
|
+
function aad(database, table, column, publicHeader, prefix = PREFIX) {
|
|
350
|
+
const parts = [Buffer.from(prefix, 'ascii')];
|
|
349
351
|
for (const name of [database, table, column]) {
|
|
350
352
|
const raw = validateName(name);
|
|
351
353
|
parts.push(u16(raw.length), raw);
|
|
@@ -355,22 +357,65 @@ module.exports = function createClientEncryption(deps) {
|
|
|
355
357
|
}
|
|
356
358
|
|
|
357
359
|
function inspectField(ciphertext) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
let prefix, version, suite, nonceSize, mode;
|
|
361
|
+
if (typeof ciphertext === 'string' && ciphertext.startsWith(PREFIX)) {
|
|
362
|
+
prefix = PREFIX; version = 1; suite = 1; nonceSize = NONCE_SIZE; mode = 'randomized';
|
|
363
|
+
} else if (typeof ciphertext === 'string' && ciphertext.startsWith(DETERMINISTIC_PREFIX)) {
|
|
364
|
+
prefix = DETERMINISTIC_PREFIX; version = 2; suite = 2; nonceSize = 0; mode = 'deterministic';
|
|
365
|
+
} else fail('invalid_format', 'invalid client ciphertext prefix');
|
|
366
|
+
const enc = ciphertext.slice(prefix.length);
|
|
360
367
|
if (!enc || enc.length % 4 === 1 || !/^[A-Za-z0-9_-]+$/.test(enc) || enc.length > Math.ceil((MAX_PLAINTEXT + 101) * 4 / 3)) {
|
|
361
368
|
fail('invalid_format', 'client ciphertext length out of range');
|
|
362
369
|
}
|
|
363
370
|
const body = Buffer.from(enc, 'base64url');
|
|
364
|
-
|
|
371
|
+
if (body.toString('base64url') !== enc || body.length < 3 + 1 + 6 + nonceSize + TAG_SIZE || body[0] !== version || body[1] !== suite) fail('invalid_format', 'unsupported or truncated client ciphertext');
|
|
365
372
|
const n = body[2];
|
|
366
|
-
|
|
373
|
+
if (n < 1 || n > MAX_KEY_ID || body.length < 3 + n + 6 + nonceSize + TAG_SIZE) fail('invalid_format', 'invalid field key id length');
|
|
367
374
|
const keyID = body.subarray(3, 3 + n).toString('ascii');
|
|
368
375
|
if (!validKeyID(keyID)) fail('invalid_format', 'invalid field key id');
|
|
369
376
|
const off = 3 + n;
|
|
370
377
|
const type = normalizeType({ kind: body[off], precision: body.readUInt16LE(off + 1), scale: body.readUInt16LE(off + 3), vecElem: body[off + 5] }, 'invalid_format');
|
|
371
|
-
|
|
378
|
+
return { keyID, type, body, headerLength: off + 6 + nonceSize, mode, prefix };
|
|
372
379
|
}
|
|
373
380
|
|
|
381
|
+
function aesBlock(key, block) {
|
|
382
|
+
const cipher = createCipheriv('aes-128-ecb', key, null);
|
|
383
|
+
cipher.setAutoPadding(false);
|
|
384
|
+
return Buffer.concat([cipher.update(block), cipher.final()]);
|
|
385
|
+
}
|
|
386
|
+
function dbl(block) {
|
|
387
|
+
const out = Buffer.alloc(16); let carry = 0;
|
|
388
|
+
for (let i = 15; i >= 0; i--) { const b = block[i]; out[i] = ((b << 1) & 0xff) | carry; carry = b >>> 7; }
|
|
389
|
+
if (carry) out[15] ^= 0x87;
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
function xor16(a, b) { const out = Buffer.alloc(16); for (let i = 0; i < 16; i++) out[i] = a[i] ^ b[i]; return out; }
|
|
393
|
+
function cmac(key, msg) {
|
|
394
|
+
const l = aesBlock(key, Buffer.alloc(16)); const k1 = dbl(l); const k2 = dbl(k1);
|
|
395
|
+
const blocks = Math.max(1, Math.ceil(msg.length / 16)); let last = Buffer.alloc(16);
|
|
396
|
+
if (msg.length && msg.length % 16 === 0) last = xor16(msg.subarray(msg.length - 16), k1);
|
|
397
|
+
else { const rem = msg.length % 16; msg.copy(last, 0, msg.length - rem); last[rem] = 0x80; last = xor16(last, k2); }
|
|
398
|
+
let y = Buffer.alloc(16);
|
|
399
|
+
for (let i = 0; i < blocks - 1; i++) y = aesBlock(key, xor16(y, msg.subarray(i * 16, i * 16 + 16)));
|
|
400
|
+
return aesBlock(key, xor16(y, last));
|
|
401
|
+
}
|
|
402
|
+
function s2v(key, vectors) {
|
|
403
|
+
let d = cmac(key, Buffer.alloc(16));
|
|
404
|
+
for (let i = 0; i < vectors.length - 1; i++) d = xor16(dbl(d), cmac(key, vectors[i]));
|
|
405
|
+
const last = vectors[vectors.length - 1]; let t;
|
|
406
|
+
if (last.length >= 16) { t = Buffer.from(last); const off = t.length - 16; for (let i = 0; i < 16; i++) t[off + i] ^= d[i]; }
|
|
407
|
+
else { const padded = Buffer.alloc(16); last.copy(padded); padded[last.length] = 0x80; t = xor16(dbl(d), padded); }
|
|
408
|
+
return cmac(key, t);
|
|
409
|
+
}
|
|
410
|
+
function sivCrypt(material, input, tag, decrypt) {
|
|
411
|
+
const q = Buffer.from(tag); q[8] &= 0x7f; q[12] &= 0x7f;
|
|
412
|
+
const cipher = decrypt ? createDecipheriv('aes-128-ctr', material.subarray(16), q) : createCipheriv('aes-128-ctr', material.subarray(16), q);
|
|
413
|
+
return Buffer.concat([cipher.update(input), cipher.final()]);
|
|
414
|
+
}
|
|
415
|
+
function deterministicKey(material) {
|
|
416
|
+
return Buffer.from(hkdfSync('sha256', material, Buffer.alloc(0), DETERMINISTIC_KEY_INFO, 32));
|
|
417
|
+
}
|
|
418
|
+
|
|
374
419
|
async function encryptField(provider, database, table, column, type, value) {
|
|
375
420
|
if (value === null || value === undefined) return null;
|
|
376
421
|
if (!provider || typeof provider.currentFieldKey !== 'function') fail('invalid_argument', 'field key provider is required');
|
|
@@ -395,7 +440,8 @@ module.exports = function createClientEncryption(deps) {
|
|
|
395
440
|
const expected = normalizeType(expectedType);
|
|
396
441
|
if (ciphertext === null || ciphertext === undefined) return null;
|
|
397
442
|
if (!provider || typeof provider.fieldKey !== 'function') fail('invalid_argument', 'field key provider is required');
|
|
398
|
-
|
|
443
|
+
const parsed = inspectField(ciphertext);
|
|
444
|
+
if (parsed.mode !== 'randomized') fail('invalid_format', 'deterministic ciphertext requires decryptFieldDeterministic');
|
|
399
445
|
if (!sameType(expected, parsed.type)) fail('invalid_format', 'encrypted logical type mismatch');
|
|
400
446
|
let key;
|
|
401
447
|
try { key = await provider.fieldKey(database, table, column, parsed.keyID); } catch { fail('crypto', 'field key unavailable or revoked'); }
|
|
@@ -415,6 +461,31 @@ module.exports = function createClientEncryption(deps) {
|
|
|
415
461
|
}
|
|
416
462
|
}
|
|
417
463
|
|
|
464
|
+
async function encryptFieldDeterministic(provider, database, table, column, type, value) {
|
|
465
|
+
if (value === null || value === undefined) return null;
|
|
466
|
+
if (!provider || typeof provider.currentFieldKey !== 'function') fail('invalid_argument', 'field key provider is required');
|
|
467
|
+
const t = normalizeType(type); const plain = encodeScalar(t, value);
|
|
468
|
+
if (plain.length > MAX_PLAINTEXT) fail('exhausted', 'plaintext exceeds field limit');
|
|
469
|
+
let key; try { key = await provider.currentFieldKey(database, table, column); } catch { fail('crypto', 'field key unavailable'); }
|
|
470
|
+
const material = deterministicKey(keyMaterial(key)); const hdr = makeHeader(key.id, t);
|
|
471
|
+
const associated = aad(database, table, column, hdr, DETERMINISTIC_PREFIX);
|
|
472
|
+
const tag = s2v(material.subarray(0, 16), [associated, plain]);
|
|
473
|
+
return DETERMINISTIC_PREFIX + Buffer.concat([hdr, tag, sivCrypt(material, plain, tag, false)]).toString('base64url');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function decryptFieldDeterministic(provider, database, table, column, expectedType, ciphertext) {
|
|
477
|
+
const expected = normalizeType(expectedType); if (ciphertext == null) return null;
|
|
478
|
+
if (!provider || typeof provider.fieldKey !== 'function') fail('invalid_argument', 'field key provider is required');
|
|
479
|
+
const parsed = inspectField(ciphertext);
|
|
480
|
+
if (parsed.mode !== 'deterministic' || !sameType(expected, parsed.type)) fail('invalid_format', 'encrypted mode or logical type mismatch');
|
|
481
|
+
let key; try { key = await provider.fieldKey(database, table, column, parsed.keyID); } catch { fail('crypto', 'field key unavailable or revoked'); }
|
|
482
|
+
const material = deterministicKey(keyMaterial(key, parsed.keyID)); const tag = parsed.body.subarray(parsed.headerLength, parsed.headerLength + 16);
|
|
483
|
+
const plain = sivCrypt(material, parsed.body.subarray(parsed.headerLength + 16), tag, true);
|
|
484
|
+
const expectedTag = s2v(material.subarray(0, 16), [aad(database, table, column, parsed.body.subarray(0, parsed.headerLength), DETERMINISTIC_PREFIX), plain]);
|
|
485
|
+
if (!timingSafeEqual(tag, expectedTag)) fail('crypto', 'ciphertext authentication failed');
|
|
486
|
+
return decodeScalar(expected, plain);
|
|
487
|
+
}
|
|
488
|
+
|
|
418
489
|
function generateFieldKey(id) {
|
|
419
490
|
if (!validKeyID(id)) fail('invalid_argument', 'invalid field key id');
|
|
420
491
|
return { id, material: randomBytes(32) };
|
|
@@ -630,5 +701,5 @@ module.exports = function createClientEncryption(deps) {
|
|
|
630
701
|
}
|
|
631
702
|
}
|
|
632
703
|
|
|
633
|
-
return { FieldType, MemoryFieldKeyring, FileFieldKeyring, decryptField, encryptField, generateFieldKey, inspectField };
|
|
704
|
+
return { FieldType, MemoryFieldKeyring, FileFieldKeyring, decryptField, decryptFieldDeterministic, encryptField, encryptFieldDeterministic, generateFieldKey, inspectField };
|
|
634
705
|
};
|
package/nextsql.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Shared TypeScript surface for the official Node.js and Bun NextSQL drivers.
|
|
2
|
+
// Keys and passwords are never accepted in a URL.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// parity test in the driver test suite fails if this drifts from the canonical
|
|
7
|
-
// shared file.
|
|
4
|
+
// drivers/node/nextsql.d.ts and drivers/bun/nextsql.d.ts are published copies
|
|
5
|
+
// of this file (npm packs only in-package files); their parity tests fail if
|
|
6
|
+
// they drift from this canonical source.
|
|
8
7
|
|
|
9
8
|
export interface TLSOptions {
|
|
10
9
|
/** PEM or DER trust material. */
|
|
@@ -31,9 +30,10 @@ export interface Config {
|
|
|
31
30
|
/** Single-node entry point. Required for connect(); optional when nodes is set. */
|
|
32
31
|
address?: string;
|
|
33
32
|
database?: string;
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
/** Reserved; must stay empty. Multi-realm hosting was removed — a
|
|
34
|
+
* deployment serves exactly one database, and a server rejects a Hello
|
|
35
|
+
* naming any other realm. The field remains only because the wire
|
|
36
|
+
* frame's trailing realm slot does. */
|
|
37
37
|
realm?: string;
|
|
38
38
|
user: string;
|
|
39
39
|
password?: string;
|
|
@@ -51,6 +51,8 @@ export interface Config {
|
|
|
51
51
|
readConsistency?: ReadConsistencyMode;
|
|
52
52
|
/** Bounded-read staleness window in milliseconds. 0 selects the server default. */
|
|
53
53
|
maxStalenessMs?: number;
|
|
54
|
+
/** Maximum number of prepared statements to cache per connection LRU. Default: 0 (disabled on raw connect; 50 in pool). */
|
|
55
|
+
statementCacheSize?: number;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export interface FieldKey {
|
|
@@ -212,6 +214,8 @@ export interface Conn {
|
|
|
212
214
|
encryptField(table: string, column: string, type: FieldTypeDescriptor, value: unknown): Promise<string | null>;
|
|
213
215
|
/** Authenticate and decode an opaque NSCE1 STRING; null passes through. */
|
|
214
216
|
decryptField(table: string, column: string, type: FieldTypeDescriptor, ciphertext: string | null): Promise<unknown>;
|
|
217
|
+
encryptFieldDeterministic(table: string, column: string, type: FieldTypeDescriptor, value: unknown): Promise<string | null>;
|
|
218
|
+
decryptFieldDeterministic(table: string, column: string, type: FieldTypeDescriptor, ciphertext: string | null): Promise<unknown>;
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
/** Routing client over every node of a NextSQL HA cluster. */
|
|
@@ -236,6 +240,8 @@ export interface HelloOK {
|
|
|
236
240
|
version: number;
|
|
237
241
|
authMethod: number;
|
|
238
242
|
secret: bigint;
|
|
243
|
+
/** Capability bits the server accepted from Hello.flags; 0 when none. */
|
|
244
|
+
flags: number;
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
export interface DecodeValue {
|
|
@@ -246,7 +252,14 @@ export interface DecodeValue {
|
|
|
246
252
|
|
|
247
253
|
export declare class NextSQLError extends Error {
|
|
248
254
|
readonly code: string;
|
|
249
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Stable ERR_* name from docs/error-codes.md, present only on an error
|
|
257
|
+
* received over a connection that negotiated the public taxonomy and empty
|
|
258
|
+
* otherwise. `code` always carries the legacy class, so retry logic should
|
|
259
|
+
* keep reading that.
|
|
260
|
+
*/
|
|
261
|
+
readonly publicCode: string;
|
|
262
|
+
constructor(code: string, message?: string, publicCode?: string);
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
export declare const Kind: {
|
|
@@ -318,15 +331,50 @@ export declare const Type: {
|
|
|
318
331
|
|
|
319
332
|
export declare function connect(cfg: Config): Promise<Conn>;
|
|
320
333
|
export declare function connectCluster(cfg: Config): Promise<Cluster>;
|
|
334
|
+
|
|
335
|
+
export interface PoolOptions {
|
|
336
|
+
/** Minimum number of idle connections to maintain. Default: 1 */
|
|
337
|
+
min?: number;
|
|
338
|
+
/** Maximum number of connections in the pool. Default: 10 */
|
|
339
|
+
max?: number;
|
|
340
|
+
/** Maximum milliseconds to wait for a connection before timing out. Default: 30000 (30s) */
|
|
341
|
+
timeoutMs?: number;
|
|
342
|
+
/** Maximum idle milliseconds before an unused connection is retired. Default: 300000 (5m) */
|
|
343
|
+
idleTimeoutMs?: number;
|
|
344
|
+
/** Maximum number of prepared statements to cache per connection LRU. Default: 50. 0 disables. */
|
|
345
|
+
statementCacheSize?: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export declare class Pool {
|
|
349
|
+
constructor(config: Config, options?: PoolOptions);
|
|
350
|
+
readonly size: number;
|
|
351
|
+
readonly idleCount: number;
|
|
352
|
+
readonly inUseCount: number;
|
|
353
|
+
readonly isClosed: boolean;
|
|
354
|
+
acquire(): Promise<Conn>;
|
|
355
|
+
release(conn: Conn): Promise<void>;
|
|
356
|
+
withConnection<T>(fn: (conn: Conn) => Promise<T>): Promise<T>;
|
|
357
|
+
exec(sql: string, params?: unknown[]): Promise<Result>;
|
|
358
|
+
query(sql: string, params?: unknown[]): Promise<Result>;
|
|
359
|
+
close(): Promise<void>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export declare function createPool(config: Config, options?: PoolOptions): Pool;
|
|
321
363
|
export declare function generateFieldKey(id: string): FieldKey;
|
|
322
364
|
export declare function inspectField(ciphertext: string): { keyID: string; type: FieldTypeDescriptor };
|
|
323
365
|
export declare function encryptField(provider: FieldKeyProvider, database: string, table: string, column: string, type: FieldTypeDescriptor, value: unknown): Promise<string | null>;
|
|
324
366
|
export declare function decryptField(provider: FieldKeyProvider, database: string, table: string, column: string, type: FieldTypeDescriptor, ciphertext: string | null): Promise<unknown>;
|
|
367
|
+
export declare function encryptFieldDeterministic(provider: FieldKeyProvider, database: string, table: string, column: string, type: FieldTypeDescriptor, value: unknown): Promise<string | null>;
|
|
368
|
+
export declare function decryptFieldDeterministic(provider: FieldKeyProvider, database: string, table: string, column: string, type: FieldTypeDescriptor, ciphertext: string | null): Promise<unknown>;
|
|
325
369
|
export declare function validateConfig(cfg: Config): void;
|
|
326
370
|
export declare function isLoopback(addr: string): boolean;
|
|
327
371
|
export declare function encodeParam(v: Param): Uint8Array;
|
|
328
372
|
export declare function struct(fields: Array<[string, unknown]>): { __struct: Array<[string, unknown]> };
|
|
329
373
|
export declare function decodeValue(buf: Uint8Array, off: number): DecodeValue;
|
|
374
|
+
/** Hello.flags bit requesting cancellation instead of a session. */
|
|
375
|
+
export declare const FlagCancel: number;
|
|
376
|
+
/** Hello.flags bit requesting the stable ERR_* error taxonomy. */
|
|
377
|
+
export declare const FlagPublicErrorCodes: number;
|
|
330
378
|
export declare function encodeHello(h: Hello): Uint8Array;
|
|
331
379
|
export declare function decodeHelloOK(b: Uint8Array): HelloOK;
|
|
332
380
|
export declare function encodeDecimalString(s: string): Uint8Array;
|
package/nextsql.js
CHANGED
|
@@ -57,6 +57,12 @@ const STATUS_TTL_MS = 500;
|
|
|
57
57
|
const AuthPassword = 1;
|
|
58
58
|
const AuthPasswordKey = 2;
|
|
59
59
|
const FlagCancel = 1;
|
|
60
|
+
// FlagPublicErrorCodes asks the server for the stable ERR_* error taxonomy
|
|
61
|
+
// (docs/error-codes.md). A server that does not implement it ignores the bit
|
|
62
|
+
// and keeps the NSQL v1 error shape, which decodeError still reads, so setting
|
|
63
|
+
// it is safe against any server version. The server echoes it in HelloOK.flags
|
|
64
|
+
// when it was accepted.
|
|
65
|
+
const FlagPublicErrorCodes = 2;
|
|
60
66
|
const FlagNull = 0x01;
|
|
61
67
|
|
|
62
68
|
const Kind = {
|
|
@@ -104,10 +110,16 @@ const MAX_STRUCT_FIELDS = 128;
|
|
|
104
110
|
const MAX_COLLECTION_LEN = 1 << 20;
|
|
105
111
|
|
|
106
112
|
class NextSQLError extends Error {
|
|
107
|
-
constructor(code, message) {
|
|
113
|
+
constructor(code, message, publicCode) {
|
|
108
114
|
super(message || code);
|
|
109
115
|
this.name = 'NextSQLError';
|
|
110
116
|
this.code = code;
|
|
117
|
+
// publicCode is the stable ERR_* name the server sent, present only on
|
|
118
|
+
// an error received over a connection that negotiated the taxonomy and
|
|
119
|
+
// empty otherwise. code always carries the legacy class, so existing
|
|
120
|
+
// retry checks keep working and nothing should branch on publicCode
|
|
121
|
+
// being present.
|
|
122
|
+
this.publicCode = publicCode || '';
|
|
111
123
|
}
|
|
112
124
|
}
|
|
113
125
|
|
|
@@ -116,7 +128,9 @@ const {
|
|
|
116
128
|
MemoryFieldKeyring,
|
|
117
129
|
FileFieldKeyring,
|
|
118
130
|
decryptField,
|
|
131
|
+
decryptFieldDeterministic,
|
|
119
132
|
encryptField,
|
|
133
|
+
encryptFieldDeterministic,
|
|
120
134
|
generateFieldKey,
|
|
121
135
|
inspectField,
|
|
122
136
|
} = require('./client-encryption')({
|
|
@@ -1279,10 +1293,19 @@ function encodeHello(h) {
|
|
|
1279
1293
|
}
|
|
1280
1294
|
|
|
1281
1295
|
function decodeHelloOK(b) {
|
|
1282
|
-
if (b.length !== 11) {
|
|
1296
|
+
if (b.length !== 11 && b.length !== 13) {
|
|
1283
1297
|
throw new NextSQLError('protocol', 'bad hello-ok length');
|
|
1284
1298
|
}
|
|
1285
|
-
|
|
1299
|
+
let flags = 0;
|
|
1300
|
+
if (b.length === 13) {
|
|
1301
|
+
flags = u16(b, 11);
|
|
1302
|
+
// The server omits the field when it accepted no capability, so a
|
|
1303
|
+
// present-but-zero field is a second encoding of the v1 hello-ok.
|
|
1304
|
+
if (flags === 0) {
|
|
1305
|
+
throw new NextSQLError('protocol', 'empty hello-ok flags');
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return { version: u16(b, 0), authMethod: b[2], secret: u64(b, 3), flags };
|
|
1286
1309
|
}
|
|
1287
1310
|
|
|
1288
1311
|
function encodeAuth(password) {
|
|
@@ -1374,7 +1397,18 @@ function decodeDataBatch(b) {
|
|
|
1374
1397
|
function decodeError(b) {
|
|
1375
1398
|
const code = readU16String(b, 0, MAX_NAME);
|
|
1376
1399
|
const msg = readU16String(b, code.next, MAX_NAME);
|
|
1377
|
-
|
|
1400
|
+
// Optional trailing field, present only from a server that accepted
|
|
1401
|
+
// FlagPublicErrorCodes. Its absence is normal — an older server ignores the
|
|
1402
|
+
// request bit — so this must never be required.
|
|
1403
|
+
let publicCode = '';
|
|
1404
|
+
if (msg.next < b.length) {
|
|
1405
|
+
const p = readU16String(b, msg.next, MAX_NAME);
|
|
1406
|
+
if (p.value === '') {
|
|
1407
|
+
throw new NextSQLError('protocol', 'empty public error code');
|
|
1408
|
+
}
|
|
1409
|
+
publicCode = p.value;
|
|
1410
|
+
}
|
|
1411
|
+
return new NextSQLError(code.value, msg.value, publicCode);
|
|
1378
1412
|
}
|
|
1379
1413
|
|
|
1380
1414
|
function decodeCommandComplete(b) {
|
|
@@ -1567,8 +1601,8 @@ function connectSocket(cfg) {
|
|
|
1567
1601
|
if (cfg.tls.ca) {
|
|
1568
1602
|
opts.ca = cfg.tls.ca;
|
|
1569
1603
|
}
|
|
1570
|
-
if (cfg.tls.rejectUnauthorized ===
|
|
1571
|
-
opts.rejectUnauthorized =
|
|
1604
|
+
if (typeof cfg.tls.rejectUnauthorized === 'boolean') {
|
|
1605
|
+
opts.rejectUnauthorized = cfg.tls.rejectUnauthorized;
|
|
1572
1606
|
}
|
|
1573
1607
|
const sock = tls.connect(opts, () => resolve(sock));
|
|
1574
1608
|
sock.on('error', onErr);
|
|
@@ -1724,11 +1758,15 @@ class Conn {
|
|
|
1724
1758
|
this.wire = wire;
|
|
1725
1759
|
this.secret = 0n;
|
|
1726
1760
|
this.busy = false;
|
|
1761
|
+
this.publicErrorCodes = false;
|
|
1762
|
+
this.statementCacheSize = cfg?.statementCacheSize ?? 0;
|
|
1763
|
+
this._stmtCache = this.statementCacheSize > 0 ? new Map() : null;
|
|
1727
1764
|
}
|
|
1728
1765
|
|
|
1729
1766
|
async handshake() {
|
|
1730
1767
|
await this.wire.writeFrame(Type.Hello, encodeHello({
|
|
1731
1768
|
version: VERSION,
|
|
1769
|
+
flags: FlagPublicErrorCodes,
|
|
1732
1770
|
database: this.cfg.database || '',
|
|
1733
1771
|
user: this.cfg.user,
|
|
1734
1772
|
realm: this.cfg.realm || '',
|
|
@@ -1739,6 +1777,9 @@ class Conn {
|
|
|
1739
1777
|
}
|
|
1740
1778
|
const ok = decodeHelloOK(msg.payload);
|
|
1741
1779
|
this.secret = ok.secret;
|
|
1780
|
+
// Diagnostic only: decodeError reads the field whenever it is present,
|
|
1781
|
+
// so nothing depends on this having been echoed.
|
|
1782
|
+
this.publicErrorCodes = (ok.flags & FlagPublicErrorCodes) !== 0;
|
|
1742
1783
|
await this.wire.writeFrame(Type.Auth, encodeAuth(this.cfg.password || ''));
|
|
1743
1784
|
msg = await this.wire.readFrame();
|
|
1744
1785
|
if (msg.type !== Type.AuthOK) {
|
|
@@ -1865,6 +1906,30 @@ class Conn {
|
|
|
1865
1906
|
if (this.busy) {
|
|
1866
1907
|
throw new NextSQLError('conflict', 'connection is busy');
|
|
1867
1908
|
}
|
|
1909
|
+
if (this._stmtCache && params && params.length > 0) {
|
|
1910
|
+
let stmt = this._stmtCache.get(sql);
|
|
1911
|
+
if (stmt) {
|
|
1912
|
+
this._stmtCache.delete(sql);
|
|
1913
|
+
this._stmtCache.set(sql, stmt);
|
|
1914
|
+
} else {
|
|
1915
|
+
stmt = await this.prepare(sql);
|
|
1916
|
+
if (this._stmtCache.size >= this.statementCacheSize) {
|
|
1917
|
+
const oldestKey = this._stmtCache.keys().next().value;
|
|
1918
|
+
const oldestStmt = this._stmtCache.get(oldestKey);
|
|
1919
|
+
this._stmtCache.delete(oldestKey);
|
|
1920
|
+
oldestStmt.close().catch(() => {});
|
|
1921
|
+
}
|
|
1922
|
+
this._stmtCache.set(sql, stmt);
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
return await this._execute(stmt.id, params);
|
|
1926
|
+
} catch (err) {
|
|
1927
|
+
this._stmtCache.delete(sql);
|
|
1928
|
+
stmt.close().catch(() => {});
|
|
1929
|
+
throw err;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1868
1933
|
this.busy = true;
|
|
1869
1934
|
try {
|
|
1870
1935
|
await this.wire.writeFrame(Type.Query, encodeQuery(sql, params || []));
|
|
@@ -1888,6 +1953,14 @@ class Conn {
|
|
|
1888
1953
|
return decryptField(this.cfg.fieldKeys, this.cfg.database || '', table, column, type, ciphertext);
|
|
1889
1954
|
}
|
|
1890
1955
|
|
|
1956
|
+
async encryptFieldDeterministic(table, column, type, value) {
|
|
1957
|
+
return encryptFieldDeterministic(this.cfg.fieldKeys, this.cfg.database || '', table, column, type, value);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async decryptFieldDeterministic(table, column, type, ciphertext) {
|
|
1961
|
+
return decryptFieldDeterministic(this.cfg.fieldKeys, this.cfg.database || '', table, column, type, ciphertext);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1891
1964
|
async prepare(sql) {
|
|
1892
1965
|
if (!this.wire) {
|
|
1893
1966
|
throw new NextSQLError('unavailable', 'connection closed');
|
|
@@ -1949,6 +2022,11 @@ class Conn {
|
|
|
1949
2022
|
}
|
|
1950
2023
|
|
|
1951
2024
|
async close() {
|
|
2025
|
+
if (this._stmtCache) {
|
|
2026
|
+
const stmts = [...this._stmtCache.values()];
|
|
2027
|
+
this._stmtCache.clear();
|
|
2028
|
+
await Promise.allSettled(stmts.map(s => s.close()));
|
|
2029
|
+
}
|
|
1952
2030
|
if (!this.wire) {
|
|
1953
2031
|
return;
|
|
1954
2032
|
}
|
|
@@ -2150,8 +2228,190 @@ async function connectCluster(cfg) {
|
|
|
2150
2228
|
return new Cluster(cfg, conns);
|
|
2151
2229
|
}
|
|
2152
2230
|
|
|
2231
|
+
class Pool {
|
|
2232
|
+
constructor(cfg, options = {}) {
|
|
2233
|
+
validateConfig(cfg);
|
|
2234
|
+
const min = options.min ?? 1;
|
|
2235
|
+
const max = options.max ?? 10;
|
|
2236
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2237
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? 300000;
|
|
2238
|
+
const statementCacheSize = options.statementCacheSize ?? 50;
|
|
2239
|
+
|
|
2240
|
+
if (min < 0) throw new NextSQLError('invalid_argument', 'min must be >= 0');
|
|
2241
|
+
if (max < 1) throw new NextSQLError('invalid_argument', 'max must be >= 1');
|
|
2242
|
+
if (max < min) throw new NextSQLError('invalid_argument', 'max must be >= min');
|
|
2243
|
+
if (statementCacheSize < 0) throw new NextSQLError('invalid_argument', 'statementCacheSize must be >= 0');
|
|
2244
|
+
|
|
2245
|
+
this._cfg = { ...cfg, statementCacheSize };
|
|
2246
|
+
this._min = min;
|
|
2247
|
+
this._max = max;
|
|
2248
|
+
this._timeoutMs = timeoutMs;
|
|
2249
|
+
this._idleTimeoutMs = idleTimeoutMs;
|
|
2250
|
+
this._statementCacheSize = statementCacheSize;
|
|
2251
|
+
|
|
2252
|
+
this._idle = []; // [{ conn, lastUsed }]
|
|
2253
|
+
this._inUse = new Set();
|
|
2254
|
+
this._waiters = []; // [{ resolve, reject, timer }]
|
|
2255
|
+
this._closed = false;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
get size() {
|
|
2259
|
+
return this._idle.length + this._inUse.size;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
get idleCount() {
|
|
2263
|
+
return this._idle.length;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
get inUseCount() {
|
|
2267
|
+
return this._inUse.size;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
get isClosed() {
|
|
2271
|
+
return this._closed;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
async acquire() {
|
|
2275
|
+
if (this._closed) {
|
|
2276
|
+
throw new NextSQLError('unavailable', 'connection pool is closed');
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const now = Date.now();
|
|
2280
|
+
while (this._idle.length > 0) {
|
|
2281
|
+
const item = this._idle.pop();
|
|
2282
|
+
if (this._idleTimeoutMs > 0 && (now - item.lastUsed) > this._idleTimeoutMs) {
|
|
2283
|
+
item.conn.close().catch(() => {});
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
if (item.conn.wire && item.conn.wire.closed) {
|
|
2287
|
+
continue;
|
|
2288
|
+
}
|
|
2289
|
+
this._inUse.add(item.conn);
|
|
2290
|
+
return item.conn;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (this.size < this._max) {
|
|
2294
|
+
const conn = await connect(this._cfg);
|
|
2295
|
+
this._inUse.add(conn);
|
|
2296
|
+
return conn;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
return new Promise((resolve, reject) => {
|
|
2300
|
+
let timer = null;
|
|
2301
|
+
if (this._timeoutMs > 0) {
|
|
2302
|
+
timer = setTimeout(() => {
|
|
2303
|
+
const idx = this._waiters.findIndex(w => w.resolve === resolve);
|
|
2304
|
+
if (idx >= 0) {
|
|
2305
|
+
this._waiters.splice(idx, 1);
|
|
2306
|
+
reject(new NextSQLError('resource_exhausted', 'connection pool exhausted, wait timeout exceeded'));
|
|
2307
|
+
}
|
|
2308
|
+
}, this._timeoutMs);
|
|
2309
|
+
}
|
|
2310
|
+
this._waiters.push({ resolve, reject, timer });
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
async release(conn) {
|
|
2315
|
+
if (!this._inUse.has(conn)) {
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
this._inUse.delete(conn);
|
|
2319
|
+
|
|
2320
|
+
if (this._closed || (conn.wire && conn.wire.closed)) {
|
|
2321
|
+
conn.close().catch(() => {});
|
|
2322
|
+
this._drainWaiters();
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// Clean connection state: roll back uncommitted transaction
|
|
2327
|
+
try {
|
|
2328
|
+
await conn.exec('ROLLBACK');
|
|
2329
|
+
} catch {
|
|
2330
|
+
conn.close().catch(() => {});
|
|
2331
|
+
this._drainWaiters();
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
while (this._waiters.length > 0) {
|
|
2336
|
+
const waiter = this._waiters.shift();
|
|
2337
|
+
if (waiter.timer) clearTimeout(waiter.timer);
|
|
2338
|
+
this._inUse.add(conn);
|
|
2339
|
+
waiter.resolve(conn);
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
this._idle.push({ conn, lastUsed: Date.now() });
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
_drainWaiters() {
|
|
2347
|
+
if (this._closed) {
|
|
2348
|
+
while (this._waiters.length > 0) {
|
|
2349
|
+
const w = this._waiters.shift();
|
|
2350
|
+
if (w.timer) clearTimeout(w.timer);
|
|
2351
|
+
w.reject(new NextSQLError('unavailable', 'connection pool is closed'));
|
|
2352
|
+
}
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (this.size < this._max && this._waiters.length > 0) {
|
|
2356
|
+
const waiter = this._waiters.shift();
|
|
2357
|
+
if (waiter.timer) clearTimeout(waiter.timer);
|
|
2358
|
+
connect(this._cfg).then(conn => {
|
|
2359
|
+
this._inUse.add(conn);
|
|
2360
|
+
waiter.resolve(conn);
|
|
2361
|
+
}).catch(err => {
|
|
2362
|
+
waiter.reject(err);
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
async withConnection(fn) {
|
|
2368
|
+
const conn = await this.acquire();
|
|
2369
|
+
try {
|
|
2370
|
+
return await fn(conn);
|
|
2371
|
+
} finally {
|
|
2372
|
+
await this.release(conn);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
async exec(sql, params) {
|
|
2377
|
+
return this.withConnection(conn => conn.exec(sql, params));
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
async query(sql, params) {
|
|
2381
|
+
return this.withConnection(async (conn) => {
|
|
2382
|
+
const rows = await conn.query(sql, params);
|
|
2383
|
+
return rows.collect();
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
async close() {
|
|
2388
|
+
if (this._closed) return;
|
|
2389
|
+
this._closed = true;
|
|
2390
|
+
|
|
2391
|
+
while (this._waiters.length > 0) {
|
|
2392
|
+
const w = this._waiters.shift();
|
|
2393
|
+
if (w.timer) clearTimeout(w.timer);
|
|
2394
|
+
w.reject(new NextSQLError('unavailable', 'connection pool is closed'));
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
const conns = [...this._idle.map(item => item.conn), ...this._inUse];
|
|
2398
|
+
this._idle = [];
|
|
2399
|
+
this._inUse.clear();
|
|
2400
|
+
|
|
2401
|
+
await Promise.allSettled(conns.map(c => c.close()));
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function createPool(cfg, options) {
|
|
2406
|
+
return new Pool(cfg, options);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2153
2409
|
module.exports = {
|
|
2154
2410
|
connect,
|
|
2411
|
+
createPool,
|
|
2412
|
+
Pool,
|
|
2413
|
+
FlagCancel,
|
|
2414
|
+
FlagPublicErrorCodes,
|
|
2155
2415
|
connectCluster,
|
|
2156
2416
|
Cluster,
|
|
2157
2417
|
NextSQLError,
|
|
@@ -2181,7 +2441,9 @@ module.exports = {
|
|
|
2181
2441
|
MemoryFieldKeyring,
|
|
2182
2442
|
FileFieldKeyring,
|
|
2183
2443
|
decryptField,
|
|
2444
|
+
decryptFieldDeterministic,
|
|
2184
2445
|
encryptField,
|
|
2446
|
+
encryptFieldDeterministic,
|
|
2185
2447
|
generateFieldKey,
|
|
2186
2448
|
inspectField,
|
|
2187
2449
|
};
|