node-firebird 2.16.0 → 2.16.2

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/README.md CHANGED
@@ -763,16 +763,29 @@ option `numericMode` controls how those result values are exposed:
763
763
 
764
764
  | Mode | Result policy |
765
765
  | :--- | :--- |
766
- | `Firebird.NUMERIC_MODE_LOSSY` | INT64-backed values are returned as `number`; INT128 uses a mixed `number`/`string` path. Unsafe coefficients may lose precision. |
766
+ | `Firebird.NUMERIC_MODE_LOSSY` | INT64-backed values are returned as `number`, losing precision beyond the safe integer range; INT128 returns a `number` for a safe coefficient and an exact scaled `string` for an unsafe one. |
767
767
  | `Firebird.NUMERIC_MODE_SAFE` | Safe coefficients are returned as `number`; unsafe coefficients as exact scaled `string`. |
768
768
  | `Firebird.NUMERIC_MODE_STRING` | All values are returned as exact scaled `string`. |
769
769
 
770
- `LOSSY` decodes INT64-backed values through JavaScript `Number`. INT128 uses a
771
- mixed number/string decoding path. For coefficients outside JavaScript's safe
772
- integer range, the result type can depend on the Firebird wire type and value,
773
- and numeric precision is not guaranteed. `LOSSY` remains the default so that
774
- adding `numericMode` does not silently change result types for applications
775
- upgrading from earlier node-firebird releases.
770
+ `LOSSY` decodes INT64-backed values through JavaScript `Number`, so a
771
+ coefficient beyond JavaScript's safe integer range loses digits silently.
772
+ INT128 instead takes a mixed path: a coefficient inside the inclusive safe
773
+ range is returned as a scaled `number`, and one outside it — in either
774
+ direction is returned as an exact scaled `string`, formatted just as
775
+ `STRING` would format it. The result type of an INT128 column therefore
776
+ depends on the value:
777
+
778
+ ```js
779
+ // numericMode: LOSSY, INT128 column
780
+ // coefficient 12345, scale -2 -> 123.45 (number)
781
+ // coefficient -12345, scale -2 -> -123.45 (number)
782
+ // coefficient 2^127-1, scale 0 -> '170141183460469231731687303715884105727'
783
+ // coefficient -2^127, scale 0 -> '-170141183460469231731687303715884105728'
784
+ ```
785
+
786
+ Use `SAFE` or `STRING` when a stable result type matters. `LOSSY` remains the
787
+ default so that adding `numericMode` does not silently change result types for
788
+ applications upgrading from earlier node-firebird releases.
776
789
 
777
790
  `SAFE` tests the raw integer coefficient against JavaScript's inclusive safe
778
791
  range (`Number.MIN_SAFE_INTEGER` through `Number.MAX_SAFE_INTEGER`) before
@@ -789,6 +802,12 @@ const db = await Firebird.attachAsync({
789
802
  // DECIMAL coefficient 420000,-4 -> '42.0000'
790
803
  ```
791
804
 
805
+ Every declared scale is supported in all three modes, including the 18
806
+ fractional digits an INT64-backed `NUMERIC(18,18)` allows and the 38 an INT128
807
+ column can declare. A Firebird coefficient represents `value * 10^scale`, so
808
+ the positive scales that dialect 1 and some legacy metadata still produce
809
+ scale the value up rather than down.
810
+
792
811
  The string literals `'lossy'`, `'safe'`, and `'string'` are accepted too,
793
812
  including in connection URIs (`?numericMode=safe`). `NULL` remains `null` in
794
813
  every mode. The option does not change `FLOAT`, `DOUBLE`, `DECFLOAT`, or input
@@ -1415,6 +1434,49 @@ Firebird.attach(options, function (err, db) {
1415
1434
  });
1416
1435
  ```
1417
1436
 
1437
+ #### The auxiliary connection, and when `attachEvent` fails
1438
+
1439
+ Events do **not** travel over the connection you attached with. `db.attachEvent()` sends
1440
+ `op_connect_request`; the server opens a fresh listening socket, replies with its address and
1441
+ port, and the driver dials that address as a **second, independent TCP connection**. Only once
1442
+ that aux socket is up is the `FbEventManager` created and handed to your callback.
1443
+
1444
+ The aux port is chosen by the server and is *not* the port you connected to, so it has to be
1445
+ reachable from the client in its own right. Two deployments commonly get this wrong:
1446
+
1447
+ - **Containers.** A Firebird container that publishes only `3050` answers `op_connect_request`
1448
+ with an aux port nothing outside the container can reach. Pin it with `RemoteAuxPort` in
1449
+ `firebird.conf` and publish that port too, or run the client on the same network.
1450
+ - **Firewalls.** A rule that DROPs the aux port (rather than REJECTing it) leaves the dial
1451
+ waiting out the operating system's connect timeout — around two minutes on Linux — before
1452
+ the failure is reported.
1453
+
1454
+ If the server reports `0.0.0.0` or `::` as the aux address — usual when it listens on all
1455
+ interfaces — the driver dials the host from your connection options instead, so that host must
1456
+ be the one reaching the aux port.
1457
+
1458
+ Since **2.16.2** a failed dial is reported to the attachment callback as an `Error`, exactly
1459
+ once, carrying Node's socket `code`. Earlier versions recorded it internally and never called
1460
+ back, so `attachEvent()` and `attachEventAsync()` hung indefinitely:
1461
+
1462
+ ```js
1463
+ db.attachEvent(function (err, evtmgr) {
1464
+ if (err) {
1465
+ // ECONNREFUSED → aux port not reachable (unpublished container port, nothing listening)
1466
+ // ETIMEDOUT → aux port filtered by a firewall
1467
+ // EHOSTUNREACH → wrong host: the address the server reported, or your connection host
1468
+ // when the server reported 0.0.0.0/::
1469
+ console.error('event attachment failed:', err.code || err.message);
1470
+ return;
1471
+ }
1472
+ // ...
1473
+ });
1474
+ ```
1475
+
1476
+ Errors on the aux socket *after* it connects are not delivered to this callback. Poll
1477
+ `evtmgr.getState().isEventConnectionOpen` if you need to detect an aux connection that dies
1478
+ mid-subscription.
1479
+
1418
1480
  ### Escaping Query values
1419
1481
 
1420
1482
  ```js
@@ -1908,10 +1970,14 @@ in bytes).
1908
1970
  Notes:
1909
1971
 
1910
1972
  - Requires wire protocol 16+ (Firebird 4.0 or newer server).
1911
- - Values are encoded from the statement's own parameter metadata, so
1912
- NUMERIC/DECIMAL scale, `BIGINT`/`INT128` (pass `BigInt`), `BOOLEAN`,
1913
- `TIMESTAMP`/`DATE`/`TIME`, `FLOAT`/`DOUBLE` and `DECFLOAT` all round-trip
1914
- exactly.
1973
+ - Values are encoded from the statement's own parameter metadata. Fixed-point
1974
+ `NUMERIC`/`DECIMAL`, `BIGINT`, and `INT128` parameters accept numbers, decimal
1975
+ strings, and `BigInt`. Decimal strings retain their exact digits; finite
1976
+ numbers are interpreted through their canonical decimal representation and
1977
+ rounded to the declared scale with ties away from zero. Use a string (or
1978
+ `BigInt` for whole values) when the input is outside JavaScript's safe integer
1979
+ range. `BOOLEAN`, `TIMESTAMP`/`DATE`/`TIME`, `FLOAT`/`DOUBLE`, and `DECFLOAT`
1980
+ use their corresponding wire types.
1915
1981
  - `BLOB` columns accept Buffers, strings, JSON-able objects, or
1916
1982
  pre-created blob quad ids: values are uploaded as transaction blobs
1917
1983
  first — all initiated back-to-back so the blob ops pipeline on the
@@ -2406,6 +2472,21 @@ If the failure is intermittent — the same code with the same credentials succe
2406
2472
 
2407
2473
  If the failure is consistent, the credentials really don't match an account for the authentication plugin in use: check `AuthServer`/`UserManager` in `firebird.conf` and remember that SRP and Legacy user managers keep separate password stores — a user created under one plugin does not automatically exist for the other.
2408
2474
 
2475
+ #### `db.attachEvent()` never calls back, or hangs, in Docker or behind a firewall
2476
+
2477
+ Upgrade to **2.16.2 or later**: before that release the driver recorded a failure to open the
2478
+ auxiliary event connection internally and never invoked the attachment callback, so
2479
+ `attachEvent()` silently stalled and `attachEventAsync()` returned a promise that never settled.
2480
+ From 2.16.2 the failure reaches your callback as an `Error` carrying Node's socket `code`.
2481
+
2482
+ The failure itself is a reachability problem, not a driver bug, and upgrading only makes it
2483
+ visible. Firebird events use a **second TCP connection** to a port the *server* chooses, which
2484
+ is not the port you connected to, so publishing `3050` alone is not enough: pin `RemoteAuxPort`
2485
+ in `firebird.conf` and publish that port too. See
2486
+ [§ The auxiliary connection](#the-auxiliary-connection-and-when-attachevent-fails) for the full
2487
+ picture, including the `0.0.0.0`/`::` case and why a DROP firewall rule delays the error by
2488
+ roughly two minutes.
2489
+
2409
2490
  ## Contributing
2410
2491
 
2411
2492
  Contributions are welcome — code, documentation, and bug reports alike.
@@ -1194,6 +1194,17 @@ class Connection {
1194
1194
  return;
1195
1195
  }
1196
1196
  }
1197
+ // Validate fixed-point values before the BLOB pre-pass. Blob uploads
1198
+ // write immediately, so deferring numeric validation until message
1199
+ // encoding could leave transaction blob state behind for a batch that
1200
+ // can never be sent.
1201
+ try {
1202
+ validateBatchFixedPointRows(input, rows);
1203
+ }
1204
+ catch (err) {
1205
+ (0, callback_1.doError)(err, callback);
1206
+ return;
1207
+ }
1197
1208
  var self = this;
1198
1209
  // BLOB pre-pass: upload every Buffer/string blob value as a
1199
1210
  // transaction blob and replace it (in a cloned row) with the quad
@@ -3065,6 +3076,46 @@ function scaleOutputLengths(output, options) {
3065
3076
  p.length = Math.min(Math.floor(p.length / colWidth) * connWidth, Math.floor(0xFFFF / connWidth) * connWidth);
3066
3077
  }
3067
3078
  }
3079
+ function scaleBatchFixedPoint(value, meta, bits, column) {
3080
+ try {
3081
+ return Xsql.toScaledInteger(value, meta.scale, bits);
3082
+ }
3083
+ catch (err) {
3084
+ var message = err instanceof Error ? err.message : String(err);
3085
+ throw new Error('Invalid fixed-point batch value for column ' + column +
3086
+ ' (' + (meta.field || '?') + '): ' + message);
3087
+ }
3088
+ }
3089
+ /** Validate values whose batch wire representation is metadata-directed.
3090
+ * This runs before BLOB uploads, which may write to the transaction as soon
3091
+ * as executeBatch starts its asynchronous pre-pass. */
3092
+ function validateBatchFixedPointRows(input, rows) {
3093
+ for (var i = 0; i < rows.length; i++) {
3094
+ for (var j = 0; j < input.length; j++) {
3095
+ var value = rows[i][j];
3096
+ if (value === null || value === undefined)
3097
+ continue;
3098
+ var bits = undefined;
3099
+ switch (input[j].type) {
3100
+ case const_1.default.SQL_SHORT:
3101
+ bits = 16;
3102
+ break;
3103
+ case const_1.default.SQL_LONG:
3104
+ bits = 32;
3105
+ break;
3106
+ case const_1.default.SQL_INT64:
3107
+ bits = 64;
3108
+ break;
3109
+ case const_1.default.SQL_INT128:
3110
+ bits = 128;
3111
+ break;
3112
+ }
3113
+ if (bits !== undefined) {
3114
+ scaleBatchFixedPoint(value, input[j], bits, j + 1);
3115
+ }
3116
+ }
3117
+ }
3118
+ }
3068
3119
  /**
3069
3120
  * Batch support: the engine requires every batch message to use EXACTLY the
3070
3121
  * statement's described input format (unlike op_execute, where the client
@@ -3104,16 +3155,6 @@ function buildBatchEncoders(input, options) {
3104
3155
  return (0, utils_1.parseDate)(v);
3105
3156
  return new Date(v);
3106
3157
  };
3107
- var scaled = function (v, scale) {
3108
- var n = typeof v === 'string' ? parseFloat(v) : Number(v);
3109
- return scale ? Math.round(n * Math.pow(10, -scale)) : n;
3110
- };
3111
- var scaledBig = function (v, scale) {
3112
- if (typeof v === 'bigint') {
3113
- return scale ? v * (10n ** BigInt(-scale)) : v;
3114
- }
3115
- return BigInt(scaled(v, scale));
3116
- };
3117
3158
  for (var j = 0; j < input.length; j++) {
3118
3159
  var meta = input[j];
3119
3160
  var column = j + 1;
@@ -3144,32 +3185,32 @@ function buildBatchEncoders(input, options) {
3144
3185
  break;
3145
3186
  case const_1.default.SQL_SHORT:
3146
3187
  // 2 bytes in the message struct (msglen), 4 on the XDR wire
3147
- encoders.push((function (m) {
3148
- return function (msg, v) { msg.addInt(scaled(v, m.scale)); };
3149
- })(meta));
3188
+ encoders.push((function (m, col) {
3189
+ return function (msg, v) { msg.addInt(Number(scaleBatchFixedPoint(v, m, 16, col))); };
3190
+ })(meta, column));
3150
3191
  align(2);
3151
3192
  offset += 2;
3152
3193
  break;
3153
3194
  case const_1.default.SQL_LONG:
3154
- encoders.push((function (m) {
3155
- return function (msg, v) { msg.addInt(scaled(v, m.scale)); };
3156
- })(meta));
3195
+ encoders.push((function (m, col) {
3196
+ return function (msg, v) { msg.addInt(Number(scaleBatchFixedPoint(v, m, 32, col))); };
3197
+ })(meta, column));
3157
3198
  align(4);
3158
3199
  offset += 4;
3159
3200
  break;
3160
3201
  case const_1.default.SQL_INT64:
3161
- encoders.push((function (m) {
3202
+ encoders.push((function (m, col) {
3162
3203
  return function (msg, v) {
3163
- msg.addInt64(typeof v === 'bigint' ? scaledBig(v, m.scale) : scaled(v, m.scale));
3204
+ msg.addInt64(scaleBatchFixedPoint(v, m, 64, col));
3164
3205
  };
3165
- })(meta));
3206
+ })(meta, column));
3166
3207
  align(8);
3167
3208
  offset += 8;
3168
3209
  break;
3169
3210
  case const_1.default.SQL_INT128:
3170
- encoders.push((function (m) {
3171
- return function (msg, v) { msg.addInt128(scaledBig(v, m.scale)); };
3172
- })(meta));
3211
+ encoders.push((function (m, col) {
3212
+ return function (msg, v) { msg.addInt128(scaleBatchFixedPoint(v, m, 128, col)); };
3213
+ })(meta, column));
3173
3214
  align(8);
3174
3215
  offset += 16;
3175
3216
  break;
@@ -9,8 +9,9 @@ declare class EventConnection {
9
9
  _xdr?: XdrReader;
10
10
  error: any;
11
11
  eventcallback: ((err: any, ret?: any) => void) | null;
12
- constructor(host: string, port: number, callback: (() => void) | undefined, db: any);
13
- _bind_events(host: string, port: number, callback?: () => void): void;
12
+ _connectSettled: boolean;
13
+ constructor(host: string, port: number, callback: ((err?: Error) => void) | undefined, db: any);
14
+ _bind_events(host: string, port: number, callback?: (err?: Error) => void): void;
14
15
  throwClosed(callback?: (err: any) => void): this;
15
16
  }
16
17
  export = EventConnection;
@@ -13,6 +13,7 @@ class EventConnection {
13
13
  this.emgr = null;
14
14
  this._isClosed = false;
15
15
  this._isOpened = false;
16
+ this._connectSettled = false;
16
17
  this._socket = net_1.default.createConnection(port, host);
17
18
  this._bind_events(host, port, callback);
18
19
  this.error = null;
@@ -20,17 +21,31 @@ class EventConnection {
20
21
  }
21
22
  _bind_events(host, port, callback) {
22
23
  var self = this;
24
+ function finishConnect(err) {
25
+ if (self._connectSettled)
26
+ return;
27
+ self._connectSettled = true;
28
+ if (callback)
29
+ callback(err);
30
+ }
23
31
  self._socket.on('close', function () {
24
32
  self._isClosed = true;
33
+ if (!self._isOpened) {
34
+ finishConnect(self.error || new Error(`Event connection to ${host}:${port} closed before connecting.`));
35
+ }
25
36
  });
26
37
  self._socket.on('error', function (e) {
27
38
  self.error = e;
39
+ if (!self._isOpened) {
40
+ if (!self._socket.destroyed)
41
+ self._socket.destroy();
42
+ finishConnect(e);
43
+ }
28
44
  });
29
45
  self._socket.on('connect', function () {
30
46
  self._isClosed = false;
31
47
  self._isOpened = true;
32
- if (callback)
33
- callback();
48
+ finishConnect();
34
49
  });
35
50
  self._socket.on('data', function (data) {
36
51
  var xdr, buf;
@@ -241,7 +241,7 @@ class XdrWriter {
241
241
  const bigValue = BigInt(value);
242
242
  const high = bigValue >> BigInt(64);
243
243
  const low = bigValue & BigInt("0xFFFFFFFFFFFFFFFF");
244
- this.buffer.writeBigUInt64BE(high, this.pos);
244
+ this.buffer.writeBigInt64BE(high, this.pos);
245
245
  this.pos += 8;
246
246
  this.buffer.writeBigUInt64BE(low, this.pos);
247
247
  this.pos += 8;
@@ -273,6 +273,14 @@ export declare class SQLParamInt128 {
273
273
  calcBlr(blr: BlrWriter): void;
274
274
  encode(data: XdrWriter): void;
275
275
  }
276
+ /**
277
+ * Convert a decimal input to the signed integer coefficient used by a
278
+ * Firebird fixed-point wire type. Numbers are interpreted through their
279
+ * canonical decimal string; strings and bigints never pass through Number.
280
+ * Digits discarded by the target scale are rounded to nearest, ties away
281
+ * from zero, matching Firebird's conversion of decimal parameter text.
282
+ */
283
+ export declare function toScaledInteger(value: number | string | bigint, scale: number, bits: 16 | 32 | 64 | 128): bigint;
276
284
  export declare class SQLParamDecFloat16 {
277
285
  value: any;
278
286
  constructor(value: any);
@@ -18,6 +18,7 @@ exports.nestCell = nestCell;
18
18
  exports.describeField = describeField;
19
19
  exports.describeFields = describeFields;
20
20
  exports.parseRecordCounts = parseRecordCounts;
21
+ exports.toScaledInteger = toScaledInteger;
21
22
  exports.encodeDateTimeParts = encodeDateTimeParts;
22
23
  const const_1 = __importDefault(require("./const"));
23
24
  const serialize_1 = require("./serialize");
@@ -27,11 +28,29 @@ const codepages_1 = require("./codepages");
27
28
  * SQLVar
28
29
  *
29
30
  ***************************************/
30
- const ScaleDivisor = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000, 10000000000, 100000000000, 1000000000000, 10000000000000, 100000000000000, 1000000000000000];
31
31
  const DateOffset = 40587, TimeCoeff = 86400000, MsPerMinute = 60000;
32
32
  const EMPTY_BUFFER = Buffer.alloc(0);
33
33
  const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
34
34
  const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
35
+ /**
36
+ * Apply a Firebird numeric scale to a value already narrowed to a JS number.
37
+ *
38
+ * This replaces the former lookup table of divisors, which only held
39
+ * 10^0..10^15: any larger scale indexed past its end and yielded NaN. That is
40
+ * reachable for INT64 (NUMERIC(18,18)) and routine for INT128, where the scale
41
+ * runs to 38. Math.pow(10, n) returns the identical double for every exponent
42
+ * the table did cover, so in-range results are unchanged.
43
+ *
44
+ * Positive scales multiply, matching decodeExactNumeric and formatScaledBigInt;
45
+ * the table path divided by them, which was the wrong direction.
46
+ */
47
+ function applyScale(value, scale) {
48
+ if (!scale)
49
+ return value;
50
+ return scale < 0
51
+ ? value / Math.pow(10, -scale)
52
+ : value * Math.pow(10, scale);
53
+ }
35
54
  /** Format a signed Firebird integer coefficient without passing through Number. */
36
55
  function formatScaledBigInt(value, scale) {
37
56
  const negative = value < 0n;
@@ -56,10 +75,16 @@ function decodeExactNumeric(value, scale, mode) {
56
75
  }
57
76
  /** Decode INT128 using the mixed number/string policy of lossy mode. */
58
77
  function decodeLossyInt128(value, scale) {
59
- if (value > MAX_SAFE_BIGINT) {
78
+ // Both bounds matter, as in decodeExactNumeric. While this path read the
79
+ // coefficient unsigned, every negative arrived as a huge positive and so
80
+ // always took the exact-string branch, which masked the missing lower
81
+ // bound. Now that the reader is signed, a large negative would otherwise
82
+ // fall through to Number() and lose precision while its positive twin
83
+ // stayed exact.
84
+ if (value > MAX_SAFE_BIGINT || value < MIN_SAFE_BIGINT) {
60
85
  return formatScaledBigInt(value, scale);
61
86
  }
62
- return Number(value) / ScaleDivisor[Math.abs(scale)];
87
+ return applyScale(Number(value), scale);
63
88
  }
64
89
  /**
65
90
  * Maps Firebird character-set names (upper-case) to the Node.js Buffer
@@ -487,9 +512,7 @@ exports.SQLVarArray = SQLVarArray;
487
512
  class SQLVarInt extends SQLVarBase {
488
513
  decode(data, lowerV13) {
489
514
  var ret = data.readInt();
490
- if (this.scale) {
491
- ret = ret / ScaleDivisor[Math.abs(this.scale)];
492
- }
515
+ ret = applyScale(ret, this.scale);
493
516
  if (!lowerV13 || !data.readInt()) {
494
517
  return ret;
495
518
  }
@@ -516,8 +539,7 @@ class SQLVarInt64 extends SQLVarBase {
516
539
  let ret;
517
540
  if (mode === const_1.default.NUMERIC_MODE_LOSSY) {
518
541
  ret = data.readInt64();
519
- if (this.scale)
520
- ret = ret / ScaleDivisor[Math.abs(this.scale)];
542
+ ret = applyScale(ret, this.scale);
521
543
  }
522
544
  else {
523
545
  ret = decodeExactNumeric(data.readInt64BigInt(), this.scale, mode);
@@ -538,7 +560,7 @@ class SQLVarInt128 extends SQLVarBase {
538
560
  decode(data, lowerV13, options) {
539
561
  const mode = options?.numericMode || const_1.default.NUMERIC_MODE_LOSSY;
540
562
  const ret = mode === const_1.default.NUMERIC_MODE_LOSSY
541
- ? decodeLossyInt128(data.readInt128(), this.scale)
563
+ ? decodeLossyInt128(data.readInt128Signed(), this.scale)
542
564
  : decodeExactNumeric(data.readInt128Signed(), this.scale, mode);
543
565
  if (!lowerV13 || !data.readInt()) {
544
566
  return ret;
@@ -813,6 +835,78 @@ class SQLParamInt128 {
813
835
  }
814
836
  exports.SQLParamInt128 = SQLParamInt128;
815
837
  //------------------------------------------------------
838
+ const FIXED_POINT_RE = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/;
839
+ /**
840
+ * Convert a decimal input to the signed integer coefficient used by a
841
+ * Firebird fixed-point wire type. Numbers are interpreted through their
842
+ * canonical decimal string; strings and bigints never pass through Number.
843
+ * Digits discarded by the target scale are rounded to nearest, ties away
844
+ * from zero, matching Firebird's conversion of decimal parameter text.
845
+ */
846
+ function toScaledInteger(value, scale, bits) {
847
+ if (!Number.isSafeInteger(scale)) {
848
+ throw new TypeError('Fixed-point scale must be an integer');
849
+ }
850
+ if (typeof value === 'number' && !Number.isFinite(value)) {
851
+ throw new TypeError('Fixed-point value must be finite');
852
+ }
853
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'bigint') {
854
+ throw new TypeError('Fixed-point value must be a number, string, or bigint');
855
+ }
856
+ const text = String(value).trim();
857
+ const match = FIXED_POINT_RE.exec(text);
858
+ if (!match) {
859
+ throw new TypeError('Invalid fixed-point value: ' + text);
860
+ }
861
+ const negative = match[1] === '-';
862
+ const integer = match[2] || '0';
863
+ const fraction = match[3] !== undefined ? match[3] : (match[4] || '');
864
+ const exponent = match[5] === undefined ? 0 : Number(match[5]);
865
+ if (!Number.isSafeInteger(exponent)) {
866
+ throw new RangeError('Fixed-point exponent is outside the supported range: ' + match[5]);
867
+ }
868
+ let digits = (integer + fraction).replace(/^0+/, '') || '0';
869
+ if (digits === '0')
870
+ return 0n;
871
+ const shift = exponent - fraction.length - scale;
872
+ let coefficientDigits;
873
+ let roundUp = false;
874
+ if (shift >= 0) {
875
+ // Every supported destination is at most 39 decimal digits. Avoid
876
+ // constructing an arbitrarily large BigInt for inputs such as 1e999999.
877
+ if (digits.length + shift > 40) {
878
+ throw new RangeError('Fixed-point value is outside the signed ' + bits + '-bit range: ' + text);
879
+ }
880
+ coefficientDigits = digits + '0'.repeat(shift);
881
+ }
882
+ else {
883
+ const discarded = -shift;
884
+ if (discarded < digits.length) {
885
+ const split = digits.length - discarded;
886
+ coefficientDigits = digits.slice(0, split);
887
+ roundUp = digits.charCodeAt(split) >= 0x35;
888
+ }
889
+ else {
890
+ coefficientDigits = '0';
891
+ // If discarded exceeds the number of significant digits, the
892
+ // magnitude is below 0.1 coefficient and cannot round to one.
893
+ roundUp = discarded === digits.length && digits.charCodeAt(0) >= 0x35;
894
+ }
895
+ }
896
+ let coefficient = BigInt(coefficientDigits);
897
+ if (roundUp)
898
+ coefficient += 1n;
899
+ if (negative)
900
+ coefficient = -coefficient;
901
+ const width = BigInt(bits);
902
+ const min = -(1n << (width - 1n));
903
+ const max = (1n << (width - 1n)) - 1n;
904
+ if (coefficient < min || coefficient > max) {
905
+ throw new RangeError('Fixed-point value is outside the signed ' + bits + '-bit range: ' + text);
906
+ }
907
+ return coefficient;
908
+ }
909
+ //------------------------------------------------------
816
910
  class SQLParamDecFloat16 {
817
911
  constructor(value) {
818
912
  this.value = value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-firebird",
3
- "version": "2.16.0",
3
+ "version": "2.16.2",
4
4
  "description": "Pure JavaScript and Asynchronous Firebird client for Node.js.",
5
5
  "keywords": [
6
6
  "firebird",
@@ -1454,6 +1454,17 @@ class Connection {
1454
1454
  }
1455
1455
  }
1456
1456
 
1457
+ // Validate fixed-point values before the BLOB pre-pass. Blob uploads
1458
+ // write immediately, so deferring numeric validation until message
1459
+ // encoding could leave transaction blob state behind for a batch that
1460
+ // can never be sent.
1461
+ try {
1462
+ validateBatchFixedPointRows(input, rows as any[][]);
1463
+ } catch (err) {
1464
+ doError(err, callback);
1465
+ return;
1466
+ }
1467
+
1457
1468
  var self = this;
1458
1469
 
1459
1470
  // BLOB pre-pass: upload every Buffer/string blob value as a
@@ -3538,6 +3549,39 @@ function scaleOutputLengths(output: any[], options: any) {
3538
3549
  }
3539
3550
  }
3540
3551
 
3552
+ function scaleBatchFixedPoint(value: any, meta: any, bits: 16 | 32 | 64 | 128, column: number): bigint {
3553
+ try {
3554
+ return Xsql.toScaledInteger(value, meta.scale, bits);
3555
+ } catch (err) {
3556
+ var message = err instanceof Error ? err.message : String(err);
3557
+ throw new Error('Invalid fixed-point batch value for column ' + column +
3558
+ ' (' + (meta.field || '?') + '): ' + message);
3559
+ }
3560
+ }
3561
+
3562
+ /** Validate values whose batch wire representation is metadata-directed.
3563
+ * This runs before BLOB uploads, which may write to the transaction as soon
3564
+ * as executeBatch starts its asynchronous pre-pass. */
3565
+ function validateBatchFixedPointRows(input: any[], rows: any[][]): void {
3566
+ for (var i = 0; i < rows.length; i++) {
3567
+ for (var j = 0; j < input.length; j++) {
3568
+ var value = rows[i][j];
3569
+ if (value === null || value === undefined) continue;
3570
+
3571
+ var bits: 16 | 32 | 64 | 128 | undefined = undefined;
3572
+ switch (input[j].type) {
3573
+ case Const.SQL_SHORT: bits = 16; break;
3574
+ case Const.SQL_LONG: bits = 32; break;
3575
+ case Const.SQL_INT64: bits = 64; break;
3576
+ case Const.SQL_INT128: bits = 128; break;
3577
+ }
3578
+ if (bits !== undefined) {
3579
+ scaleBatchFixedPoint(value, input[j], bits, j + 1);
3580
+ }
3581
+ }
3582
+ }
3583
+ }
3584
+
3541
3585
  /**
3542
3586
  * Batch support: the engine requires every batch message to use EXACTLY the
3543
3587
  * statement's described input format (unlike op_execute, where the client
@@ -3573,17 +3617,6 @@ function buildBatchEncoders(input: any[], options: any) {
3573
3617
  if (typeof v === 'string') return parseDate(v);
3574
3618
  return new Date(v);
3575
3619
  };
3576
- var scaled = function(v: any, scale: number): number {
3577
- var n = typeof v === 'string' ? parseFloat(v) : Number(v);
3578
- return scale ? Math.round(n * Math.pow(10, -scale)) : n;
3579
- };
3580
- var scaledBig = function(v: any, scale: number): bigint {
3581
- if (typeof v === 'bigint') {
3582
- return scale ? v * (10n ** BigInt(-scale)) : v;
3583
- }
3584
- return BigInt(scaled(v, scale));
3585
- };
3586
-
3587
3620
  for (var j = 0; j < input.length; j++) {
3588
3621
  var meta = input[j];
3589
3622
  var column = j + 1;
@@ -3616,32 +3649,32 @@ function buildBatchEncoders(input: any[], options: any) {
3616
3649
 
3617
3650
  case Const.SQL_SHORT:
3618
3651
  // 2 bytes in the message struct (msglen), 4 on the XDR wire
3619
- encoders.push((function(m) {
3620
- return function(msg: any, v: any) { msg.addInt(scaled(v, m.scale)); };
3621
- })(meta));
3652
+ encoders.push((function(m, col) {
3653
+ return function(msg: any, v: any) { msg.addInt(Number(scaleBatchFixedPoint(v, m, 16, col))); };
3654
+ })(meta, column));
3622
3655
  align(2); offset += 2;
3623
3656
  break;
3624
3657
 
3625
3658
  case Const.SQL_LONG:
3626
- encoders.push((function(m) {
3627
- return function(msg: any, v: any) { msg.addInt(scaled(v, m.scale)); };
3628
- })(meta));
3659
+ encoders.push((function(m, col) {
3660
+ return function(msg: any, v: any) { msg.addInt(Number(scaleBatchFixedPoint(v, m, 32, col))); };
3661
+ })(meta, column));
3629
3662
  align(4); offset += 4;
3630
3663
  break;
3631
3664
 
3632
3665
  case Const.SQL_INT64:
3633
- encoders.push((function(m) {
3666
+ encoders.push((function(m, col) {
3634
3667
  return function(msg: any, v: any) {
3635
- msg.addInt64(typeof v === 'bigint' ? (scaledBig(v, m.scale) as any) : scaled(v, m.scale));
3668
+ msg.addInt64(scaleBatchFixedPoint(v, m, 64, col));
3636
3669
  };
3637
- })(meta));
3670
+ })(meta, column));
3638
3671
  align(8); offset += 8;
3639
3672
  break;
3640
3673
 
3641
3674
  case Const.SQL_INT128:
3642
- encoders.push((function(m) {
3643
- return function(msg: any, v: any) { msg.addInt128(scaledBig(v, m.scale)); };
3644
- })(meta));
3675
+ encoders.push((function(m, col) {
3676
+ return function(msg: any, v: any) { msg.addInt128(scaleBatchFixedPoint(v, m, 128, col)); };
3677
+ })(meta, column));
3645
3678
  align(8); offset += 16;
3646
3679
  break;
3647
3680
 
@@ -13,37 +13,49 @@ class EventConnection {
13
13
  _xdr?: XdrReader;
14
14
  error: any;
15
15
  eventcallback: ((err: any, ret?: any) => void) | null;
16
+ _connectSettled: boolean;
16
17
 
17
- constructor(host: string, port: number, callback: (() => void) | undefined, db: any) {
18
+ constructor(host: string, port: number, callback: ((err?: Error) => void) | undefined, db: any) {
18
19
  var self = this;
19
20
  this.db = db;
20
21
  this.emgr = null;
21
22
  this._isClosed = false;
22
23
  this._isOpened = false;
24
+ this._connectSettled = false;
23
25
  this._socket = net.createConnection(port, host);
24
26
  this._bind_events(host, port, callback);
25
27
  this.error = null;
26
28
  this.eventcallback = null;
27
29
  }
28
30
 
29
- _bind_events(host: string, port: number, callback?: () => void): void {
31
+ _bind_events(host: string, port: number, callback?: (err?: Error) => void): void {
30
32
  var self = this;
31
33
 
32
- self._socket.on('close', function () {
34
+ function finishConnect(err?: Error) {
35
+ if (self._connectSettled) return;
36
+ self._connectSettled = true;
37
+ if (callback) callback(err);
38
+ }
33
39
 
40
+ self._socket.on('close', function () {
34
41
  self._isClosed = true;
42
+ if (!self._isOpened) {
43
+ finishConnect(self.error || new Error(`Event connection to ${host}:${port} closed before connecting.`));
44
+ }
35
45
  })
36
46
 
37
47
  self._socket.on('error', function (e) {
38
-
39
48
  self.error = e;
49
+ if (!self._isOpened) {
50
+ if (!self._socket.destroyed) self._socket.destroy();
51
+ finishConnect(e);
52
+ }
40
53
  })
41
54
 
42
55
  self._socket.on('connect', function () {
43
56
  self._isClosed = false;
44
57
  self._isOpened = true;
45
- if (callback)
46
- callback();
58
+ finishConnect();
47
59
  });
48
60
 
49
61
  self._socket.on('data', function (data: Buffer) {
@@ -303,7 +303,7 @@ export class XdrWriter {
303
303
  const high = bigValue >> BigInt(64);
304
304
  const low = bigValue & BigInt("0xFFFFFFFFFFFFFFFF");
305
305
 
306
- this.buffer.writeBigUInt64BE(high, this.pos);
306
+ this.buffer.writeBigInt64BE(high, this.pos);
307
307
  this.pos += 8;
308
308
  this.buffer.writeBigUInt64BE(low, this.pos);
309
309
  this.pos += 8;
@@ -11,8 +11,6 @@ import type { NumericMode, RecordCounts } from '../types';
11
11
  *
12
12
  ***************************************/
13
13
 
14
- const
15
- ScaleDivisor = [1,10,100,1000,10000,100000,1000000,10000000,100000000,1000000000,10000000000, 100000000000,1000000000000,10000000000000,100000000000000,1000000000000000];
16
14
  const
17
15
  DateOffset = 40587,
18
16
  TimeCoeff = 86400000,
@@ -26,6 +24,25 @@ type NumericDecodeOptions = {
26
24
  numericMode?: NumericMode;
27
25
  };
28
26
 
27
+ /**
28
+ * Apply a Firebird numeric scale to a value already narrowed to a JS number.
29
+ *
30
+ * This replaces the former lookup table of divisors, which only held
31
+ * 10^0..10^15: any larger scale indexed past its end and yielded NaN. That is
32
+ * reachable for INT64 (NUMERIC(18,18)) and routine for INT128, where the scale
33
+ * runs to 38. Math.pow(10, n) returns the identical double for every exponent
34
+ * the table did cover, so in-range results are unchanged.
35
+ *
36
+ * Positive scales multiply, matching decodeExactNumeric and formatScaledBigInt;
37
+ * the table path divided by them, which was the wrong direction.
38
+ */
39
+ function applyScale(value: number, scale: number): number {
40
+ if (!scale) return value;
41
+ return scale < 0
42
+ ? value / Math.pow(10, -scale)
43
+ : value * Math.pow(10, scale);
44
+ }
45
+
29
46
  /** Format a signed Firebird integer coefficient without passing through Number. */
30
47
  function formatScaledBigInt(value: bigint, scale: number): string {
31
48
  const negative = value < 0n;
@@ -51,11 +68,17 @@ function decodeExactNumeric(value: bigint, scale: number, mode: 'safe' | 'string
51
68
 
52
69
  /** Decode INT128 using the mixed number/string policy of lossy mode. */
53
70
  function decodeLossyInt128(value: bigint, scale: number): number | string {
54
- if (value > MAX_SAFE_BIGINT) {
71
+ // Both bounds matter, as in decodeExactNumeric. While this path read the
72
+ // coefficient unsigned, every negative arrived as a huge positive and so
73
+ // always took the exact-string branch, which masked the missing lower
74
+ // bound. Now that the reader is signed, a large negative would otherwise
75
+ // fall through to Number() and lose precision while its positive twin
76
+ // stayed exact.
77
+ if (value > MAX_SAFE_BIGINT || value < MIN_SAFE_BIGINT) {
55
78
  return formatScaledBigInt(value, scale);
56
79
  }
57
80
 
58
- return Number(value) / ScaleDivisor[Math.abs(scale)];
81
+ return applyScale(Number(value), scale);
59
82
  }
60
83
 
61
84
  /**
@@ -564,9 +587,7 @@ export class SQLVarInt extends SQLVarBase {
564
587
  decode(data: XdrReader, lowerV13: boolean) {
565
588
  var ret = data.readInt();
566
589
 
567
- if (this.scale) {
568
- ret = ret / ScaleDivisor[Math.abs(this.scale)];
569
- }
590
+ ret = applyScale(ret, this.scale);
570
591
 
571
592
  if (!lowerV13 || !data.readInt()) {
572
593
  return ret;
@@ -599,7 +620,7 @@ export class SQLVarInt64 extends SQLVarBase {
599
620
 
600
621
  if (mode === Const.NUMERIC_MODE_LOSSY) {
601
622
  ret = data.readInt64();
602
- if (this.scale) ret = ret / ScaleDivisor[Math.abs(this.scale)];
623
+ ret = applyScale(ret, this.scale);
603
624
  } else {
604
625
  ret = decodeExactNumeric(data.readInt64BigInt(), this.scale, mode);
605
626
  }
@@ -622,7 +643,7 @@ export class SQLVarInt128 extends SQLVarBase {
622
643
  decode(data: XdrReader, lowerV13: boolean, options?: NumericDecodeOptions) {
623
644
  const mode = options?.numericMode || Const.NUMERIC_MODE_LOSSY;
624
645
  const ret = mode === Const.NUMERIC_MODE_LOSSY
625
- ? decodeLossyInt128(data.readInt128(), this.scale)
646
+ ? decodeLossyInt128(data.readInt128Signed(), this.scale)
626
647
  : decodeExactNumeric(data.readInt128Signed(), this.scale, mode);
627
648
 
628
649
  if (!lowerV13 || !data.readInt()) {
@@ -957,6 +978,83 @@ export class SQLParamInt128 {
957
978
 
958
979
  //------------------------------------------------------
959
980
 
981
+ const FIXED_POINT_RE = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/;
982
+
983
+ /**
984
+ * Convert a decimal input to the signed integer coefficient used by a
985
+ * Firebird fixed-point wire type. Numbers are interpreted through their
986
+ * canonical decimal string; strings and bigints never pass through Number.
987
+ * Digits discarded by the target scale are rounded to nearest, ties away
988
+ * from zero, matching Firebird's conversion of decimal parameter text.
989
+ */
990
+ export function toScaledInteger(value: number | string | bigint, scale: number, bits: 16 | 32 | 64 | 128): bigint {
991
+ if (!Number.isSafeInteger(scale)) {
992
+ throw new TypeError('Fixed-point scale must be an integer');
993
+ }
994
+ if (typeof value === 'number' && !Number.isFinite(value)) {
995
+ throw new TypeError('Fixed-point value must be finite');
996
+ }
997
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'bigint') {
998
+ throw new TypeError('Fixed-point value must be a number, string, or bigint');
999
+ }
1000
+
1001
+ const text = String(value).trim();
1002
+ const match = FIXED_POINT_RE.exec(text);
1003
+ if (!match) {
1004
+ throw new TypeError('Invalid fixed-point value: ' + text);
1005
+ }
1006
+
1007
+ const negative = match[1] === '-';
1008
+ const integer = match[2] || '0';
1009
+ const fraction = match[3] !== undefined ? match[3] : (match[4] || '');
1010
+ const exponent = match[5] === undefined ? 0 : Number(match[5]);
1011
+ if (!Number.isSafeInteger(exponent)) {
1012
+ throw new RangeError('Fixed-point exponent is outside the supported range: ' + match[5]);
1013
+ }
1014
+
1015
+ let digits = (integer + fraction).replace(/^0+/, '') || '0';
1016
+ if (digits === '0') return 0n;
1017
+
1018
+ const shift = exponent - fraction.length - scale;
1019
+ let coefficientDigits: string;
1020
+ let roundUp = false;
1021
+
1022
+ if (shift >= 0) {
1023
+ // Every supported destination is at most 39 decimal digits. Avoid
1024
+ // constructing an arbitrarily large BigInt for inputs such as 1e999999.
1025
+ if (digits.length + shift > 40) {
1026
+ throw new RangeError('Fixed-point value is outside the signed ' + bits + '-bit range: ' + text);
1027
+ }
1028
+ coefficientDigits = digits + '0'.repeat(shift);
1029
+ } else {
1030
+ const discarded = -shift;
1031
+ if (discarded < digits.length) {
1032
+ const split = digits.length - discarded;
1033
+ coefficientDigits = digits.slice(0, split);
1034
+ roundUp = digits.charCodeAt(split) >= 0x35;
1035
+ } else {
1036
+ coefficientDigits = '0';
1037
+ // If discarded exceeds the number of significant digits, the
1038
+ // magnitude is below 0.1 coefficient and cannot round to one.
1039
+ roundUp = discarded === digits.length && digits.charCodeAt(0) >= 0x35;
1040
+ }
1041
+ }
1042
+
1043
+ let coefficient = BigInt(coefficientDigits);
1044
+ if (roundUp) coefficient += 1n;
1045
+ if (negative) coefficient = -coefficient;
1046
+
1047
+ const width = BigInt(bits);
1048
+ const min = -(1n << (width - 1n));
1049
+ const max = (1n << (width - 1n)) - 1n;
1050
+ if (coefficient < min || coefficient > max) {
1051
+ throw new RangeError('Fixed-point value is outside the signed ' + bits + '-bit range: ' + text);
1052
+ }
1053
+ return coefficient;
1054
+ }
1055
+
1056
+ //------------------------------------------------------
1057
+
960
1058
  export class SQLParamDecFloat16 {
961
1059
  value: any;
962
1060