node-firebird 2.16.0 → 2.16.1

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
@@ -1908,10 +1927,14 @@ in bytes).
1908
1927
  Notes:
1909
1928
 
1910
1929
  - 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.
1930
+ - Values are encoded from the statement's own parameter metadata. Fixed-point
1931
+ `NUMERIC`/`DECIMAL`, `BIGINT`, and `INT128` parameters accept numbers, decimal
1932
+ strings, and `BigInt`. Decimal strings retain their exact digits; finite
1933
+ numbers are interpreted through their canonical decimal representation and
1934
+ rounded to the declared scale with ties away from zero. Use a string (or
1935
+ `BigInt` for whole values) when the input is outside JavaScript's safe integer
1936
+ range. `BOOLEAN`, `TIMESTAMP`/`DATE`/`TIME`, `FLOAT`/`DOUBLE`, and `DECFLOAT`
1937
+ use their corresponding wire types.
1915
1938
  - `BLOB` columns accept Buffers, strings, JSON-able objects, or
1916
1939
  pre-created blob quad ids: values are uploaded as transaction blobs
1917
1940
  first — all initiated back-to-back so the blob ops pipeline on the
@@ -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;
@@ -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.1",
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
 
@@ -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