node-firebird 2.12.0 → 2.13.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.
@@ -8,6 +8,7 @@ import * as srp from '../srp';
8
8
  import * as crypt from '../unix-crypt';
9
9
  import Const from './const';
10
10
  import * as Xsql from './xsqlvar';
11
+ import { charsetWidthById } from './codepages';
11
12
  import ServiceManager from './service';
12
13
  import Database from './database';
13
14
  import Statement from './statement';
@@ -78,6 +79,21 @@ function statementCacheLimit(options: InternalOptions): number {
78
79
  // SQL type-code names live in xsqlvar.ts alongside the descriptors
79
80
  const SQL_TYPE_NAMES = Xsql.SQL_TYPE_NAMES;
80
81
 
82
+ /**
83
+ * Build the wire parameter for a text value in the CONNECTION charset:
84
+ * plain SQLParamString for UTF-8 connections, pre-encoded bytes for
85
+ * everything else — SQLParamString hardwires utf8, which corrupted
86
+ * writes on latin1/codepage connections (issues #319/#301).
87
+ */
88
+ function textParam(options: InternalOptions, value: string) {
89
+ const codec = Xsql.resolveTextCodec(options);
90
+ const enc = Xsql.resolveTextEncoding(options);
91
+ if (!codec && enc === 'utf8') {
92
+ return new Xsql.SQLParamString(value);
93
+ }
94
+ return new Xsql.SQLParamBuffer(Xsql.encodeConnectionText(options, value));
95
+ }
96
+
81
97
  /**
82
98
  * Run the user's typeCast hook (options.typeCast) for one column value.
83
99
  * The hook receives the column metadata and a next() returning the value
@@ -263,6 +279,25 @@ class Connection {
263
279
  }
264
280
 
265
281
 
282
+ /**
283
+ * Deliver a connection-level error to 'error' listeners — and ONLY to
284
+ * listeners. Emitting an unlistened 'error' makes Node throw the error
285
+ * object as an uncaught exception; for errors that originate in
286
+ * background contexts (the reconnect timer, socket-level failures whose
287
+ * operations are separately rejected via _rejectPending) that crashes
288
+ * the process — or, under a test runner, fails whatever unrelated test
289
+ * happens to be running. The failing operations themselves always
290
+ * still receive their error through their own callbacks.
291
+ */
292
+ _emitError(err: any) {
293
+ if (this.db && typeof this.db.listenerCount === 'function' && this.db.listenerCount('error') > 0) {
294
+ this.db.emit('error', err);
295
+ } else if (process.env.FIREBIRD_DEBUG) {
296
+ console.warn('[fb-debug] connection error (no error listener):', err && err.message);
297
+ }
298
+ }
299
+
300
+
266
301
  _bind_events(host: string, port: number, callback: SimpleCallback | undefined) {
267
302
 
268
303
  var self = this;
@@ -287,6 +322,15 @@ class Connection {
287
322
 
288
323
  self._rejectPending(lostError);
289
324
 
325
+ // Pooled connections do not self-reconnect: the pool is the
326
+ // recovery authority (dead-connection check on checkout + the
327
+ // reaper), and a background reconnect here would produce a
328
+ // zombie attachment the pool no longer tracks — whose own
329
+ // failures then surface as uncatchable async errors.
330
+ if (self.options && self.options.isPool) {
331
+ return;
332
+ }
333
+
290
334
  self._retry_connection_id = setTimeout(function() {
291
335
  self._socket.removeAllListeners();
292
336
  // transiently null while the replacement Connection is built
@@ -297,14 +341,14 @@ class Connection {
297
341
  ctx.connect(self.options, function(err: any) {
298
342
 
299
343
  if (err) {
300
- self.db.emit('error', err);
344
+ self._emitError(err);
301
345
  return;
302
346
  }
303
347
 
304
348
  ctx.attach(self.options, function(err: any) {
305
349
 
306
350
  if (err) {
307
- self.db.emit('error', err);
351
+ self._emitError(err);
308
352
  return;
309
353
  }
310
354
 
@@ -321,11 +365,13 @@ class Connection {
321
365
  });
322
366
 
323
367
  self._socket.on('error', function(e: any) {
324
-
368
+
325
369
  self.error = e;
326
-
327
- if (self.db)
328
- self.db.emit('error', e)
370
+
371
+ // listeners only (_emitError): the affected operations get their
372
+ // errors via the close handler's _rejectPending — an unlistened
373
+ // socket error must not become an uncaught exception
374
+ self._emitError(e);
329
375
 
330
376
  if (callback)
331
377
  callback(e);
@@ -767,7 +813,9 @@ class Connection {
767
813
  blr.pos = 0;
768
814
 
769
815
  blr.addByte(Const.isc_dpb_version1);
770
- blr.addString(Const.isc_dpb_lc_ctype, options.encoding || 'UTF8', Const.DEFAULT_ENCODING);
816
+ // charset names are sent verbatim — normalize case so encoding:
817
+ // 'utf8'/'win1251' (e.g. from URI query params) is accepted
818
+ blr.addString(Const.isc_dpb_lc_ctype, String(options.encoding || 'UTF8').toUpperCase(), Const.DEFAULT_ENCODING);
771
819
 
772
820
  // For Firebird 3+ (protocol 13+), add UTF-8 filename flag to ensure all DPB strings are handled with UTF-8
773
821
  if (this.accept.protocolVersion >= Const.PROTOCOL_VERSION13) {
@@ -895,8 +943,15 @@ class Connection {
895
943
 
896
944
  blr.pos = 0;
897
945
  blr.addByte(Const.isc_dpb_version1);
898
- blr.addString(Const.isc_dpb_set_db_charset, 'UTF8', Const.DEFAULT_ENCODING);
899
- blr.addString(Const.isc_dpb_lc_ctype, 'UTF8', Const.DEFAULT_ENCODING);
946
+ // honour options.encoding on the CREATE path too — hardcoding UTF8
947
+ // for lc_ctype made attachOrCreate silently ignore the requested
948
+ // connection charset (issue #319: 'Malformed string' for codepage
949
+ // text). The new database's DEFAULT charset follows the connection
950
+ // encoding unless options.defaultCharset overrides it.
951
+ blr.addString(Const.isc_dpb_set_db_charset,
952
+ String(options.defaultCharset || options.encoding || 'UTF8').toUpperCase(), Const.DEFAULT_ENCODING);
953
+ blr.addString(Const.isc_dpb_lc_ctype,
954
+ String(options.encoding || 'UTF8').toUpperCase(), Const.DEFAULT_ENCODING);
900
955
 
901
956
  // For Firebird 3+ (protocol 13+), add UTF-8 filename flag to ensure all DPB strings are handled with UTF-8
902
957
  if (this.accept.protocolVersion >= Const.PROTOCOL_VERSION13) {
@@ -1004,7 +1059,9 @@ class Connection {
1004
1059
 
1005
1060
  throwClosed(callback: ((err: Error, ...args: any[]) => void) | undefined) {
1006
1061
  var err = new Error('Connection is closed.');
1007
- this.db.emit('error', err);
1062
+ // listeners only: the caller receives the error through its own
1063
+ // callback below either way
1064
+ this._emitError(err);
1008
1065
  if (callback)
1009
1066
  callback(err);
1010
1067
  return this;
@@ -1254,7 +1311,9 @@ class Connection {
1254
1311
  msg.addInt(transaction.handle);
1255
1312
  msg.addInt(0xFFFF);
1256
1313
  msg.addInt(3); // dialect = 3
1257
- msg.addString(query, Const.DEFAULT_ENCODING);
1314
+ // SQL text travels in the CONNECTION charset (codepage
1315
+ // connections encode via codec — greek/cyrillic literals included)
1316
+ msg.addStringBuffer(Xsql.encodeConnectionText(this.options, query));
1258
1317
  msg.addBlr(blr);
1259
1318
  msg.addInt(65535); // buffer_length
1260
1319
  if (this.accept.protocolVersion >= Const.PROTOCOL_VERSION20) {
@@ -1316,7 +1375,9 @@ class Connection {
1316
1375
  msg.addInt(transaction.handle);
1317
1376
  msg.addInt(statement.handle);
1318
1377
  msg.addInt(3); // dialect = 3
1319
- msg.addString(query, Const.DEFAULT_ENCODING);
1378
+ // SQL text travels in the CONNECTION charset (codepage
1379
+ // connections encode via codec — greek/cyrillic literals included)
1380
+ msg.addStringBuffer(Xsql.encodeConnectionText(this.options, query));
1320
1381
  msg.addBlr(blr);
1321
1382
  msg.addInt(65535); // buffer_length
1322
1383
  if (this.accept.protocolVersion >= Const.PROTOCOL_VERSION20) {
@@ -1348,10 +1409,13 @@ class Connection {
1348
1409
  * gets an in-order response (op_batch_cs for exec), so the regular
1349
1410
  * response queue keeps everything in sync.
1350
1411
  *
1351
- * rows: array of parameter arrays, one per record. BLOB/ARRAY columns
1352
- * are not supported yet. The callback receives a completion object:
1353
- * { recordCount, updateCounts, errors: [{recordNumber, error}],
1354
- * errorRecordNumbers, success }.
1412
+ * rows: array of parameter arrays, one per record. BLOB columns accept
1413
+ * Buffers, strings, JSON-able objects or pre-created blob quad ids
1414
+ * values are uploaded as transaction blobs first (all initiated
1415
+ * back-to-back so they pipeline) and the batch messages reference their
1416
+ * ids. ARRAY columns are not supported. The callback receives a
1417
+ * completion object: { recordCount, updateCounts, errors:
1418
+ * [{recordNumber, error}], errorRecordNumbers, success }.
1355
1419
  */
1356
1420
  executeBatch(transaction: Transaction, statement: Statement, rows: QueryParams[], callback: BatchCb | undefined, options?: BatchOptions) {
1357
1421
  options = options || {};
@@ -1389,6 +1453,90 @@ class Connection {
1389
1453
  }
1390
1454
  }
1391
1455
 
1456
+ var self = this;
1457
+
1458
+ // BLOB pre-pass: upload every Buffer/string blob value as a
1459
+ // transaction blob and replace it (in a cloned row) with the quad
1460
+ // id the batch message will carry. All uploads are initiated
1461
+ // back-to-back, so the create/segment/close ops pipeline on the
1462
+ // wire instead of paying a round trip per blob.
1463
+ var blobCols: number[] = [];
1464
+ for (var bj = 0; bj < input.length; bj++) {
1465
+ var bt = input[bj].type;
1466
+ if (bt === Const.SQL_BLOB || bt === Const.SQL_QUAD) {
1467
+ blobCols.push(bj);
1468
+ }
1469
+ }
1470
+
1471
+ // all-NULL blob columns are common — a large row set must not pay
1472
+ // for a full clone when there is nothing to upload or unwrap
1473
+ var needsBlobPass = false;
1474
+ if (blobCols.length) {
1475
+ outer:
1476
+ for (var ri = 0; ri < rows.length; ri++) {
1477
+ for (var ci = 0; ci < blobCols.length; ci++) {
1478
+ var bv = (rows[ri] as any[])[blobCols[ci]];
1479
+ if (bv !== null && bv !== undefined) {
1480
+ needsBlobPass = true;
1481
+ break outer;
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+
1487
+ if (needsBlobPass) {
1488
+ var cloned: any[][] = rows.map(function(r) { return (r as any[]).slice(); });
1489
+ var pendingBlobs = 1; // sentinel so zero uploads still settle
1490
+ var blobFailure: any = null;
1491
+ var settleBlobs = function(err?: any) {
1492
+ if (err && !blobFailure) {
1493
+ blobFailure = err;
1494
+ }
1495
+ if (--pendingBlobs) {
1496
+ return;
1497
+ }
1498
+ if (blobFailure) {
1499
+ doError(blobFailure, callback);
1500
+ return;
1501
+ }
1502
+ self._executeBatchEncoded(transaction, statement, cloned, callback, options);
1503
+ };
1504
+
1505
+ cloned.forEach(function(row) {
1506
+ blobCols.forEach(function(j) {
1507
+ var v = row[j];
1508
+ if (v === null || v === undefined) {
1509
+ return;
1510
+ }
1511
+ // a pre-created blob id (SQLParamQuad wrapper) passes
1512
+ // through; plain {high, low} objects are deliberately NOT
1513
+ // treated as ids — they are legitimate JSON blob content
1514
+ // and would silently misroute to a bogus blob reference
1515
+ if (v instanceof Xsql.SQLParamQuad) {
1516
+ row[j] = v.value;
1517
+ return;
1518
+ }
1519
+ pendingBlobs++;
1520
+ self.uploadBlob(transaction, v, function(err: any, oid: any) {
1521
+ if (!err) {
1522
+ row[j] = oid;
1523
+ }
1524
+ settleBlobs(err);
1525
+ });
1526
+ });
1527
+ });
1528
+ settleBlobs();
1529
+ return;
1530
+ }
1531
+
1532
+ this._executeBatchEncoded(transaction, statement, rows as any[][], callback, options);
1533
+ }
1534
+
1535
+
1536
+ /** Encode and send the batch packets (rows are fully materialized:
1537
+ * blob values already replaced by quad ids by executeBatch). */
1538
+ _executeBatchEncoded(transaction: Transaction, statement: Statement, rows: any[][], callback: BatchCb | undefined, options: BatchOptions) {
1539
+ var input = statement.input;
1392
1540
  var built;
1393
1541
  try {
1394
1542
  built = buildBatchEncoders(input, Object.assign({}, this.options, options));
@@ -1584,9 +1732,9 @@ class Connection {
1584
1732
  if (Buffer.isBuffer(value))
1585
1733
  b = value;
1586
1734
  else if (typeof(value) === 'string')
1587
- b = Buffer.from(value, Const.DEFAULT_ENCODING);
1735
+ b = Xsql.encodeConnectionText(self.options, value);
1588
1736
  else if (!isStream)
1589
- b = Buffer.from(JSON.stringify(value), Const.DEFAULT_ENCODING);
1737
+ b = Xsql.encodeConnectionText(self.options, JSON.stringify(value));
1590
1738
 
1591
1739
  // Use configured transfer size or default to 1024
1592
1740
  var chunkSize = self.options.blobChunkSize || 1024;
@@ -1737,14 +1885,18 @@ class Connection {
1737
1885
  ret[i] = new Xsql.SQLParamDouble(value);
1738
1886
  break;
1739
1887
  case 'string':
1740
- ret[i] = new Xsql.SQLParamString(value);
1888
+ ret[i] = textParam(self.options, value);
1741
1889
  break;
1742
1890
  case 'boolean':
1743
- ret[i] = new Xsql.SQLParamBool(value);
1891
+ // metadata-directed: BOOLEAN targets get a
1892
+ // real blr_bool (issue #122); smallint
1893
+ // targets keep the legacy 0/1 (BOOLEAN
1894
+ // does not convert to numbers)
1895
+ ret[i] = new Xsql.SQLParamBool(value, meta.type === Const.SQL_BOOLEAN);
1744
1896
  break;
1745
1897
  default:
1746
1898
  //throw new Error('Unexpected parametter: ' + JSON.stringify(params) + ' - ' + JSON.stringify(input));
1747
- ret[i] = new Xsql.SQLParamString(value.toString());
1899
+ ret[i] = textParam(self.options, value.toString());
1748
1900
  break;
1749
1901
  }
1750
1902
  }
@@ -2005,6 +2157,37 @@ class Connection {
2005
2157
  }
2006
2158
 
2007
2159
 
2160
+ /**
2161
+ * Resolve the pending blobAsText fetches of a decoded row batch
2162
+ * (ret.arrBlob) and write the text back into ret.data. Reads run
2163
+ * sequentially to respect Firebird's per-connection open-blob-handle
2164
+ * limit (issue #387). Used by fetchAll for cursors and by
2165
+ * transaction.execute for op_execute2 singletons (EXECUTE PROCEDURE /
2166
+ * RETURNING — issue #305, whose blobs never resolved before).
2167
+ */
2168
+ resolveTextBlobs(transaction: Transaction, ret: any, callback: (err?: any) => void) {
2169
+ const self = this;
2170
+ const fns = (ret && ret.arrBlob) || [];
2171
+ if (!fns.length) {
2172
+ return callback();
2173
+ }
2174
+ const read = (index: number) => {
2175
+ if (index >= fns.length) {
2176
+ return callback();
2177
+ }
2178
+ fns[index](transaction).then((blob: any) => {
2179
+ // nestTables === true rows: the value lives in the
2180
+ // per-table sub-object, not on the row itself
2181
+ Xsql.nestCell(ret.data[blob.row], blob.table)[blob.column] = applyTypeCast(
2182
+ self.options, blob.meta || {},
2183
+ parseValueIfJson(blob.value, self.options));
2184
+ read(index + 1);
2185
+ }, callback);
2186
+ };
2187
+ read(0);
2188
+ }
2189
+
2190
+
2008
2191
  fetchAll(statement: Statement, transaction: Transaction, callback: Callback<any[]>) {
2009
2192
  const self = this;
2010
2193
  const custom = statement.options || {};
@@ -2018,29 +2201,10 @@ class Connection {
2018
2201
  }
2019
2202
 
2020
2203
  if (ret && ret.data && ret.data.length) {
2021
- // Read blobs sequentially instead of in parallel to avoid
2022
- // exceeding Firebird's per-connection open-blob-handle limit,
2023
- // which causes a server-side deadlock when many rows contain
2024
- // BLOBs and blobAsText is true. See issue #387.
2025
- const arrBlobFns = ret.arrBlob || [];
2026
- const readBlobsSequentially = (index: any, results: any) => {
2027
- if (index >= arrBlobFns.length) {
2028
- return Promise.resolve(results);
2029
- }
2030
- return arrBlobFns[index](transaction).then((v: any) => {
2031
- results.push(v);
2032
- return readBlobsSequentially(index + 1, results);
2033
- });
2034
- };
2035
-
2036
- readBlobsSequentially(0, []).then((arrBlob: any) => {
2037
- for (let i = 0; i < arrBlob.length; i++) {
2038
- const blob = arrBlob[i];
2039
- // nestTables === true rows: the value lives in the
2040
- // per-table sub-object, not on the row itself
2041
- Xsql.nestCell(ret.data[blob.row], blob.table)[blob.column] = applyTypeCast(
2042
- statement.connection.options, blob.meta || {},
2043
- parseValueIfJson(blob.value, statement.connection.options));
2204
+ self.resolveTextBlobs(transaction, ret, (blobErr?: any) => {
2205
+ if (blobErr) {
2206
+ callback(blobErr);
2207
+ return;
2044
2208
  }
2045
2209
 
2046
2210
  doSynchronousLoop(ret.data, (row, _i, next) => {
@@ -2062,7 +2226,7 @@ class Connection {
2062
2226
  self.fetch(statement, transaction, Const.DEFAULT_FETCHSIZE, loop);
2063
2227
  }
2064
2228
  });
2065
- }).catch(callback);
2229
+ });
2066
2230
  return;
2067
2231
  }
2068
2232
 
@@ -2134,6 +2298,48 @@ class Connection {
2134
2298
  }
2135
2299
 
2136
2300
 
2301
+ /**
2302
+ * Create a transaction blob, upload `value` (Buffer, string, or a
2303
+ * JSON-able object) and deliver its quad id. executeBatch's blob
2304
+ * pre-pass uses this: batch messages reference pre-created transaction
2305
+ * blobs (the batch parameter buffer's default BLOB_NONE policy), just
2306
+ * like the classic execute path stores blob params.
2307
+ */
2308
+ uploadBlob(transaction: Transaction, value: any, callback: (err: any, oid?: any) => void) {
2309
+ var self = this;
2310
+ var b: Buffer;
2311
+ if (Buffer.isBuffer(value)) {
2312
+ b = value;
2313
+ } else if (typeof value === 'string') {
2314
+ b = Xsql.encodeConnectionText(this.options, value);
2315
+ } else {
2316
+ b = Xsql.encodeConnectionText(this.options, JSON.stringify(value));
2317
+ }
2318
+
2319
+ self.createBlob2(transaction, function(err: any, blob: any) {
2320
+ if (err) {
2321
+ return callback(err);
2322
+ }
2323
+ var chunkSize = self.options.blobChunkSize || 1024;
2324
+ // bufferReader's next() drops errors — capture the first segment
2325
+ // failure so a truncated upload is never reported as success
2326
+ var segmentError: any = null;
2327
+ bufferReader(b, chunkSize, function(part, next) {
2328
+ self.batchSegments(blob, part, function(segErr: any) {
2329
+ if (segErr && !segmentError) {
2330
+ segmentError = segErr;
2331
+ }
2332
+ next();
2333
+ } as any);
2334
+ }, function() {
2335
+ self.closeBlob(blob, function(closeErr: any) {
2336
+ callback(segmentError || closeErr, blob.oid);
2337
+ } as any, false);
2338
+ });
2339
+ });
2340
+ }
2341
+
2342
+
2137
2343
  svcattach(options: InternalOptions, callback?: Callback<ServiceManager>, svc?: ServiceManager) {
2138
2344
  this._lowercase_keys = options.lowercase_keys || Const.DEFAULT_LOWERCASE_KEYS;
2139
2345
  var database = options.database || options.filename;
@@ -3288,6 +3494,43 @@ function describe(buff: Buffer, statement: Statement) {
3288
3494
  }
3289
3495
  unpackCharSetCollation(statement.input);
3290
3496
  unpackCharSetCollation(statement.output);
3497
+ scaleOutputLengths(statement.output, statement.connection && statement.connection.options);
3498
+ }
3499
+
3500
+ /**
3501
+ * Widen declared OUTPUT byte lengths for text columns whose charset the
3502
+ * server did NOT transliterate in the describe. Under a UTF8 connection
3503
+ * the server rewrites known charsets (a WIN1251 VARCHAR(15) is described
3504
+ * as UTF8 with length 60) — but NONE (and any untransliterated charset)
3505
+ * keeps its native length, and the engine's fetch-time capacity check
3506
+ * then treats those bytes as connection-charset characters: VARCHAR(15)
3507
+ * NONE declared as 15 bytes only fits floor(15/4) = 3 characters and
3508
+ * longer values fail with "string right truncation" (issue #422).
3509
+ * Declaring charCapacity × connectionWidth bytes makes the check pass;
3510
+ * the width-based trim in the text decoders restores the column's true
3511
+ * character capacity. The native length is preserved for metadata.
3512
+ */
3513
+ function scaleOutputLengths(output: any[], options: any) {
3514
+ if (!output || !output.length) return;
3515
+ const connWidth = Xsql.getFirebirdCharsetWidth(options && options.encoding);
3516
+ if (connWidth <= 1) return;
3517
+
3518
+ for (let i = 0; i < output.length; i++) {
3519
+ const p = output[i];
3520
+ if (!p || (p.type !== Const.SQL_TEXT && p.type !== Const.SQL_VARYING)) continue;
3521
+ // OCTETS (id 1) is binary and never transliterated
3522
+ if (p.charSetId === undefined || p.charSetId === 1) continue;
3523
+ const colWidth = charsetWidthById(p.charSetId);
3524
+ if (colWidth >= connWidth) continue;
3525
+
3526
+ p.nativeLength = p.length;
3527
+ // BLR encodes the length as a 16-bit word — cap the widened value
3528
+ // (a >16KB single-byte column keeps its full byte capacity, it just
3529
+ // can't be over-declared four-fold)
3530
+ p.length = Math.min(
3531
+ Math.floor(p.length / colWidth) * connWidth,
3532
+ Math.floor(0xFFFF / connWidth) * connWidth);
3533
+ }
3291
3534
  }
3292
3535
 
3293
3536
  /**
@@ -3313,7 +3556,7 @@ function buildBatchEncoders(input: any[], options: any) {
3313
3556
  return String(v);
3314
3557
  };
3315
3558
  var toBytes = function(v: any, meta: any, column: number): Buffer {
3316
- var b = Buffer.isBuffer(v) ? v : Buffer.from(toText(v), Const.DEFAULT_ENCODING);
3559
+ var b = Buffer.isBuffer(v) ? v : Xsql.encodeConnectionText(options, toText(v));
3317
3560
  if (b.length > meta.length) {
3318
3561
  throw new Error('Batch value for column ' + column + ' is ' + b.length +
3319
3562
  ' bytes but the column accepts at most ' + meta.length + ' (' + (meta.field || '?') + ')');
@@ -3451,6 +3694,24 @@ function buildBatchEncoders(input: any[], options: any) {
3451
3694
  align(4); offset += 4;
3452
3695
  break;
3453
3696
 
3697
+ case Const.SQL_BLOB:
3698
+ case Const.SQL_QUAD:
3699
+ // the value is a transaction blob quad id — placed by
3700
+ // executeBatch's uploadBlob pre-pass, or passed by the
3701
+ // caller directly. ISC_QUAD: two longs, align 4.
3702
+ encoders.push((function(col) {
3703
+ return function(msg: any, v: any) {
3704
+ if (!v || typeof v.high !== 'number' || typeof v.low !== 'number') {
3705
+ throw new Error('Batch value for BLOB column ' + col +
3706
+ ' must be a Buffer, string, object, or blob quad id');
3707
+ }
3708
+ msg.addInt(v.high);
3709
+ msg.addInt(v.low);
3710
+ };
3711
+ })(column));
3712
+ align(4); offset += 8;
3713
+ break;
3714
+
3454
3715
  default:
3455
3716
  throw new Error('executeBatch does not support the type of parameter ' + column +
3456
3717
  ' yet (' + (meta.field || '?') + ', SQL type ' + meta.type + ')');
@@ -3508,12 +3769,16 @@ function CalcBlr(blr: BlrWriter, xsqlda: any[]) {
3508
3769
 
3509
3770
  function fetch_blob_async_transaction(statement: Statement, id: Quad, column: string | number, row: number, meta?: Xsql.SQLVarBase, table?: string) {
3510
3771
  const infoValue = { row, column, value: '', meta, table };
3772
+ // decode ONCE from the concatenated bytes: per-segment toString() both
3773
+ // ignored the connection charset (#301 — cyrillic mojibake) and could
3774
+ // split a multi-byte UTF-8 character across segment boundaries
3775
+ const chunks: Buffer[] = [];
3511
3776
 
3512
3777
  return (transactionArg: any) => {
3513
3778
  const cacheKey = `${id.high}:${id.low}`;
3514
3779
  if (statement.connection._inlineBlobs && statement.connection._inlineBlobs.has(cacheKey)) {
3515
3780
  const data = statement.connection._inlineBlobs.get(cacheKey);
3516
- infoValue.value = data ? data.toString(Const.DEFAULT_ENCODING) : '';
3781
+ infoValue.value = data ? Xsql.decodeConnectionText(statement.connection.options, data) : '';
3517
3782
  return Promise.resolve(infoValue);
3518
3783
  }
3519
3784
 
@@ -3557,8 +3822,7 @@ function fetch_blob_async_transaction(statement: Statement, id: Quad, column: st
3557
3822
 
3558
3823
  if (ret.buffer) {
3559
3824
  const blr = new BlrReader(ret.buffer);
3560
- const data = blr.readSegment();
3561
- infoValue.value += data.toString(Const.DEFAULT_ENCODING);
3825
+ chunks.push(blr.readSegment());
3562
3826
  }
3563
3827
 
3564
3828
  if (ret.handle !== 2) {
@@ -3566,6 +3830,8 @@ function fetch_blob_async_transaction(statement: Statement, id: Quad, column: st
3566
3830
  return;
3567
3831
  }
3568
3832
 
3833
+ infoValue.value = Xsql.decodeConnectionText(
3834
+ statement.connection.options, Buffer.concat(chunks));
3569
3835
  statement.connection.closeBlob(blob);
3570
3836
  if (singleTransaction) {
3571
3837
  transaction.commit((err: any) => {
@@ -1,12 +1,13 @@
1
1
  import Events from 'events';
2
2
  import { doError, fromCallback, type Callback, type SimpleCallback } from '../callback';
3
- import { escape } from '../utils';
3
+ import { batchResultToError, escape } from '../utils';
4
4
  import Const from './const';
5
5
  import { makeSqlTag, type SqlTag } from '../sql-template';
6
6
  import { computeColumnKeys, nestCell, resolveKeyTransform, resolveNestTables } from './xsqlvar';
7
7
  import EventConnection from './eventConnection';
8
8
  import FbEventManager from './fbEventManager';
9
9
  import makeQueryStream from './query-stream';
10
+ import makeBatchStream from './batch-stream';
10
11
  import type Connection from './connection';
11
12
  import type Transaction from './transaction';
12
13
  import type Statement from './statement';
@@ -300,12 +301,7 @@ class Database extends Events.EventEmitter {
300
301
 
301
302
  if (!result.success) {
302
303
  transaction.rollback(function() {
303
- var first = result.errors.length ? result.errors[0] : null;
304
- var batchError: any = first
305
- ? first.error
306
- : new Error('Batch failed for record(s) ' + result.errorRecordNumbers.join(', '));
307
- batchError.batchCompletion = result;
308
- doError(batchError, callback);
304
+ doError(batchResultToError(result), callback);
309
305
  });
310
306
  return;
311
307
  }
@@ -415,6 +411,18 @@ class Database extends Events.EventEmitter {
415
411
  return makeQueryStream(this, query, params, options);
416
412
  }
417
413
 
414
+ /**
415
+ * Bulk-insert Writable (the COPY FROM analogue, Firebird 4.0+): write
416
+ * parameter-array rows, they are flushed in chunks through the batch
417
+ * API on one prepared statement. Runs its own transaction — committed
418
+ * on finish, rolled back on error/destroy (all-or-nothing for the
419
+ * whole stream). BLOB columns accept Buffers/strings. After 'finish',
420
+ * stream.recordCount / stream.affectedRows carry the totals.
421
+ */
422
+ batchStream(query: string, options?: any) {
423
+ return makeBatchStream(this, query, options, true);
424
+ }
425
+
418
426
  query(query: string, params?: QueryParams | Callback, callback?: any, options: InternalQueryOptions = {}): this {
419
427
  if (params instanceof Function) {
420
428
  options = callback || {};
@@ -346,6 +346,17 @@ export class XdrWriter {
346
346
  this.pos += alen;
347
347
  }
348
348
 
349
+ /** addString for pre-encoded bytes (codepage connection charsets). */
350
+ addStringBuffer(b: Buffer): void {
351
+ var alen = align(b.length);
352
+ this.ensure(alen + 4);
353
+ this.buffer.writeInt32BE(b.length, this.pos);
354
+ this.pos += 4;
355
+ b.copy(this.buffer, this.pos);
356
+ this.buffer.fill(0, this.pos + b.length, this.pos + alen);
357
+ this.pos += alen;
358
+ }
359
+
349
360
  addText(s: string, encoding: BufferEncoding): void {
350
361
  var len = Buffer.byteLength(s, encoding);
351
362
  var alen = align(len);
@@ -163,9 +163,15 @@ class Socket {
163
163
  * Compress and/or encrypt data before sending to socket.
164
164
  */
165
165
  write(data: Buffer | Uint8Array, defer = false): void {
166
+ // Callers pass views of the connection's shared _msg buffer, and both
167
+ // net.Socket (when it cannot flush immediately) and zlib keep a
168
+ // REFERENCE to the chunk — a later sender rebuilding _msg would then
169
+ // corrupt this queued packet (intermittent, load-dependent). Own the
170
+ // bytes at the send boundary, once.
171
+ data = Buffer.from(data);
166
172
  if (process.env.FIREBIRD_DEBUG) {
167
173
  console.log('[fb-debug] socket.write: length=%d bytes=%s encrypt=%s defer=%s',
168
- data.length, Buffer.from(data).toString('hex'), this.encrypt, defer);
174
+ data.length, (data as Buffer).toString('hex'), this.encrypt, defer);
169
175
  }
170
176
  if (defer) {
171
177
  // Accumulate deferred packets instead of overwriting. Multiple
@@ -174,8 +180,8 @@ class Socket {
174
180
  // overwriting the buffer silently drops packets and desynchronises
175
181
  // the request/response queue, causing the connection to hang.
176
182
  this.buffer = this.buffer
177
- ? Buffer.concat([this.buffer, Buffer.from(data)])
178
- : Buffer.from(data);
183
+ ? Buffer.concat([this.buffer, data])
184
+ : (data as Buffer);
179
185
  return;
180
186
  }
181
187