@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.
@@ -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
- const out = Buffer.alloc(3 + id.length + 6 + NONCE_SIZE);
340
- out[0] = 1; out[1] = 1; out[2] = id.length; id.copy(out, 3);
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
- nonce.copy(out, off + 6);
345
+ if (nonce) nonce.copy(out, off + 6);
344
346
  return out;
345
347
  }
346
348
 
347
- function aad(database, table, column, publicHeader) {
348
- const parts = [Buffer.from(PREFIX, 'ascii')];
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
- if (typeof ciphertext !== 'string' || !ciphertext.startsWith(PREFIX)) fail('invalid_format', 'invalid client ciphertext prefix');
359
- const enc = ciphertext.slice(PREFIX.length);
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
- if (body.toString('base64url') !== enc || body.length < 38 || body[0] !== 1 || body[1] !== 1) fail('invalid_format', 'unsupported or truncated client ciphertext');
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
- if (n < 1 || n > MAX_KEY_ID || body.length < 3 + n + 6 + NONCE_SIZE + TAG_SIZE) fail('invalid_format', 'invalid field key id length');
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
- return { keyID, type, body, headerLength: off + 6 + NONCE_SIZE };
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
- const parsed = inspectField(ciphertext);
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
- // Public TypeScript surface for the official NextSQL Node.js driver.
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
- // Self-contained COPY of drivers/js/types.d.ts. It must NOT import from ../js:
4
- // the package tarball contains only the files in package.json "files", so a
5
- // parent-directory import resolves to nothing for an installed consumer. The
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
- /** Selects which hosted realm this connection targets (M2-2). Optional:
35
- * an unset realm sends the exact pre-realm Hello and connects to the
36
- * server's configured default. */
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
- constructor(code: string, message?: string);
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
- return { version: u16(b, 0), authMethod: b[2], secret: u64(b, 3) };
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
- return new NextSQLError(code.value, msg.value);
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 === false) {
1571
- opts.rejectUnauthorized = false;
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bzync/nextsql",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Official NextSQL Node.js driver. Keys never go in URLs.",
5
5
  "license": "MIT",
6
6
  "main": "nextsql.js",