qvdjs 2.0.0 → 2.0.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
@@ -38,7 +38,7 @@ files, and validate output in your own Qlik environment** before relying on it.
38
38
  Qlik file and writing it back reproduces its symbol table byte for byte, whichever way it was read; a
39
39
  value you change is written as Qlik stores that kind of value. Where that boundary lies is the first
40
40
  thing worth reading:
41
- [What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v1.0/overview/what-a-round-trip-preserves/).
41
+ [What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v2.0/overview/what-a-round-trip-preserves/).
42
42
 
43
43
  ## Checked against Qlik Sense
44
44
 
@@ -65,7 +65,7 @@ Two differences are by design: Qlik often gives a field more bits in the index t
65
65
  where qvdjs uses the fewest, and qvdjs adds no tags or number formats of its own - Qlik works tags out
66
66
  when it loads a file. QVD is also QlikView's format, but QlikView is not part of the comparison.
67
67
 
68
- → [Checked against Qlik Sense](https://qvdjs.ptarmiganlabs.com/v1.0/overview/checked-against-qlik-sense/)
68
+ → [Checked against Qlik Sense](https://qvdjs.ptarmiganlabs.com/v2.0/overview/checked-against-qlik-sense/)
69
69
 
70
70
  ## Four ways to open a file
71
71
 
@@ -107,7 +107,7 @@ Reaching for `fromQvd` when you wanted one of the other three is the common mist
107
107
  `fromQvd(path, {maxRows: 0})` is **not** a substitute for `readMetadata`: it loads no rows but still
108
108
  parses the whole symbol table, which grows with the data.
109
109
 
110
- → [Choosing an entry point](https://qvdjs.ptarmiganlabs.com/v1.0/getting-started/)
110
+ → [Choosing an entry point](https://qvdjs.ptarmiganlabs.com/v2.0/getting-started/)
111
111
 
112
112
  ## What a cell holds
113
113
 
@@ -171,7 +171,7 @@ NULL. A dual value - a `QvdDual`, or an object with exactly the keys `number` an
171
171
  as a dual, which is how a number gets the text Qlik displays. As in Qlik, a field holds one text per
172
172
  number, the first written. Anything else is refused with a `QvdValidationError`.
173
173
 
174
- → [Writing a QVD](https://qvdjs.ptarmiganlabs.com/v1.0/guides/)
174
+ → [Writing a QVD](https://qvdjs.ptarmiganlabs.com/v2.0/guides/)
175
175
 
176
176
  ## Reading part of a file
177
177
 
@@ -211,7 +211,7 @@ try {
211
211
  When it is `iterate()` that overflowed, the context carries `recommendedChunkSize` as well, so a
212
212
  caller cannot mistake one recommendation for the other.
213
213
 
214
- → [Memory and file size limits](https://qvdjs.ptarmiganlabs.com/v1.0/overview/memory-and-file-size-limits/)
214
+ → [Memory and file size limits](https://qvdjs.ptarmiganlabs.com/v2.0/overview/memory-and-file-size-limits/)
215
215
 
216
216
  ## Metadata
217
217
 
@@ -228,7 +228,7 @@ df.setFileMetadata({tableName: 'UpdatedProducts'});
228
228
  await df.toQvd('products-v2.qvd');
229
229
  ```
230
230
 
231
- → [Metadata reference](https://qvdjs.ptarmiganlabs.com/v1.0/reference/)
231
+ → [Metadata reference](https://qvdjs.ptarmiganlabs.com/v2.0/reference/)
232
232
 
233
233
  ## File paths are sandboxed
234
234
 
@@ -252,7 +252,7 @@ is a different file at that name. Either is a `QvdSecurityError` whose `context.
252
252
  swapped for a symlink in the moment between the check and the open — is the one gap left, so keep
253
253
  the allowed directory's own subdirectories out of untrusted hands.
254
254
 
255
- → [Path security](https://qvdjs.ptarmiganlabs.com/v1.0/overview/path-security/)
255
+ → [Path security](https://qvdjs.ptarmiganlabs.com/v2.0/overview/path-security/)
256
256
 
257
257
  ## Errors
258
258
 
@@ -273,7 +273,7 @@ without reporting an error; `error.context.filePosition` says where the file sto
273
273
  `error.name` is always the class name, in the CommonJS and ES module builds alike, so it works across
274
274
  a module boundary where `instanceof` may not.
275
275
 
276
- → [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v1.0/troubleshooting/)
276
+ → [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v2.0/troubleshooting/)
277
277
 
278
278
  ## Known limitations
279
279
 
@@ -286,7 +286,7 @@ Honest boundaries rather than an issue list — these are the ones that change w
286
286
  | **A killed process can leave a temporary file** | A write builds `<name>.qvdjs-<hex>.tmp` beside the QVD and removes it however the write ends — unless the process is killed outright, or the removal is itself refused, in which case the error names the file in `context.temporaryFile`. The QVD is untouched either way, and such a file can be deleted. `{atomic: false}` writes none when it rewrites a QVD that exists, and damages the QVD instead; a QVD that does not exist yet is renamed into place in either mode. |
287
287
  | **The writer takes numbers, strings, dual values and `null`** | Anything else - a `Date`, a boolean, an array, a `QvdSymbol` - is refused with a `QvdValidationError` naming the field and the row. Convert first: a `Date` to `new QvdDual(dateToQlikSerial(date), text)`, the serial and the text Qlik shows, which is how Qlik stores a date; a boolean to `-1` and `0`, as a Qlik comparison stores it. |
288
288
 
289
- → [What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v1.0/overview/what-a-round-trip-preserves/)
289
+ → [What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v2.0/overview/what-a-round-trip-preserves/)
290
290
 
291
291
  ## Documentation
292
292
 
@@ -295,13 +295,13 @@ where everything above is covered properly:
295
295
 
296
296
  | | |
297
297
  | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
298
- | [Getting started](https://qvdjs.ptarmiganlabs.com/v1.0/getting-started/) | Install, choose an entry point, first read and write |
299
- | [Guides](https://qvdjs.ptarmiganlabs.com/v1.0/guides/) | One page per task, runnable sample code |
300
- | [Concepts](https://qvdjs.ptarmiganlabs.com/v1.0/concepts/) | The QVD format, symbols and duals, bit stuffing, the memory model |
301
- | [Reference](https://qvdjs.ptarmiganlabs.com/v1.0/reference/) | Every class, method, option and error |
302
- | [Performance](https://qvdjs.ptarmiganlabs.com/v1.0/performance/) | Measured baselines, and how to read them |
303
- | [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v1.0/troubleshooting/) | What each failure means, and what to do about it |
304
- | [Checked against Qlik Sense](https://qvdjs.ptarmiganlabs.com/v1.0/overview/checked-against-qlik-sense/) | How qvdjs's output is compared with Qlik Sense's |
298
+ | [Getting started](https://qvdjs.ptarmiganlabs.com/v2.0/getting-started/) | Install, choose an entry point, first read and write |
299
+ | [Guides](https://qvdjs.ptarmiganlabs.com/v2.0/guides/) | One page per task, runnable sample code |
300
+ | [Concepts](https://qvdjs.ptarmiganlabs.com/v2.0/concepts/) | The QVD format, symbols and duals, bit stuffing, the memory model |
301
+ | [Reference](https://qvdjs.ptarmiganlabs.com/v2.0/reference/) | Every class, method, option and error |
302
+ | [Performance](https://qvdjs.ptarmiganlabs.com/v2.0/performance/) | Measured baselines, and how to read them |
303
+ | [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v2.0/troubleshooting/) | What each failure means, and what to do about it |
304
+ | [Checked against Qlik Sense](https://qvdjs.ptarmiganlabs.com/v2.0/overview/checked-against-qlik-sense/) | How qvdjs's output is compared with Qlik Sense's |
305
305
 
306
306
  Benchmarks run weekly and publish to
307
307
  [ptarmiganlabs.github.io/qvdjs](https://ptarmiganlabs.github.io/qvdjs/).
package/dist/index.cjs CHANGED
@@ -1098,8 +1098,12 @@ function fieldGeometry(bitOffset, bitWidth) {
1098
1098
  byteCount: bitWidth === 0 ? 0 : shift + bitWidth + 7 >>> 3
1099
1099
  };
1100
1100
  }
1101
- function decodeIndexColumn(buffer, recordSize, rowCount, bitOffset, bitWidth, bias, out) {
1101
+ function decodeIndexColumn(buffer, recordSize, rowCount, bitOffset, bitWidth, bias, out, bounds = null) {
1102
+ const symbolCount = bounds === null ? Infinity : bounds.symbolCount;
1102
1103
  if (bitWidth === 0) {
1104
+ if (bounds !== null && rowCount > 0 && bias >= symbolCount) {
1105
+ refuse2(bounds, 0, bias, bias);
1106
+ }
1103
1107
  out.fill(bias, 0, rowCount);
1104
1108
  return out;
1105
1109
  }
@@ -1113,10 +1117,25 @@ function decodeIndexColumn(buffer, recordSize, rowCount, bitOffset, bitWidth, bi
1113
1117
  if (byteCount > 2) acc += buffer[base + 2] * 65536;
1114
1118
  if (byteCount > 3) acc += buffer[base + 3] * 16777216;
1115
1119
  if (byteCount > 4) acc += buffer[base + 4] * 4294967296;
1116
- out[row] = Math.floor(acc / divisor) % modulus + bias;
1120
+ const index = Math.floor(acc / divisor) % modulus + bias;
1121
+ if ((index >= symbolCount || index < 0 && index !== bias) && bounds !== null) {
1122
+ refuse2(bounds, row, index, bias);
1123
+ }
1124
+ out[row] = index;
1117
1125
  }
1118
1126
  return out;
1119
1127
  }
1128
+ function refuse2(bounds, row, index, bias) {
1129
+ throw new exports.QvdCorruptedError("Symbol index out of range", {
1130
+ field: bounds.field,
1131
+ row: bounds.firstRow + row,
1132
+ symbolIndex: index,
1133
+ symbolCount: bounds.symbolCount,
1134
+ bias,
1135
+ file: bounds.file,
1136
+ stage: "parseIndexTable"
1137
+ });
1138
+ }
1120
1139
  function writeBitField(buffer, recordBase, geometry, value) {
1121
1140
  const { byteStart, shift, byteCount } = geometry;
1122
1141
  if (byteCount === 0) {
@@ -1133,10 +1152,12 @@ function writeBitField(buffer, recordBase, geometry, value) {
1133
1152
  var MAX_BIT_WIDTH, POW2;
1134
1153
  var init_bitUtils = __esm({
1135
1154
  "src/util/bitUtils.js"() {
1155
+ init_QvdErrors();
1136
1156
  MAX_BIT_WIDTH = 31;
1137
1157
  POW2 = Array.from({ length: 41 }, (_, exponent) => 2 ** exponent);
1138
1158
  __name(fieldGeometry, "fieldGeometry");
1139
1159
  __name(decodeIndexColumn, "decodeIndexColumn");
1160
+ __name(refuse2, "refuse");
1140
1161
  __name(writeBitField, "writeBitField");
1141
1162
  }
1142
1163
  });
@@ -2570,6 +2591,15 @@ function validateFieldBitMetadata(field, recordSize, filePath) {
2570
2591
  stage: "parseIndexTable"
2571
2592
  });
2572
2593
  }
2594
+ if (bias < -2147483648 || bias + 2 ** bitWidth - 1 > 2 ** 31 - 1) {
2595
+ throw new exports.QvdCorruptedError("Bias out of range", {
2596
+ field: field["FieldName"],
2597
+ bias,
2598
+ bitWidth,
2599
+ file: filePath,
2600
+ stage: "parseIndexTable"
2601
+ });
2602
+ }
2573
2603
  const recordSizeInBits = recordSize * 8;
2574
2604
  if (bitOffset + bitWidth > recordSizeInBits) {
2575
2605
  throw new exports.QvdCorruptedError("Bit field extends beyond record size", {
@@ -2697,7 +2727,10 @@ function parseFieldSymbols(symbolBuffer, start, end, keep, fieldName, filePath)
2697
2727
  }
2698
2728
  return { numbers, texts };
2699
2729
  }
2700
- var MAX_TEXT_BYTES;
2730
+ function countFieldSymbols(symbolBuffer, start, end, fieldName, filePath) {
2731
+ return parseFieldSymbols(symbolBuffer, start, end, DECODE_NOTHING, fieldName, filePath).numbers.length;
2732
+ }
2733
+ var MAX_TEXT_BYTES, DECODE_NOTHING;
2701
2734
  var init_symbolParser = __esm({
2702
2735
  "src/util/symbolParser.js"() {
2703
2736
  init_QvdErrors();
@@ -2705,6 +2738,8 @@ var init_symbolParser = __esm({
2705
2738
  __name(textEnd, "textEnd");
2706
2739
  __name(overflow, "overflow");
2707
2740
  __name(parseFieldSymbols, "parseFieldSymbols");
2741
+ DECODE_NOTHING = /* @__PURE__ */ new Set();
2742
+ __name(countFieldSymbols, "countFieldSymbols");
2708
2743
  }
2709
2744
  });
2710
2745
 
@@ -3218,7 +3253,7 @@ async function* chunksFrom(handle, chunkSize, failed) {
3218
3253
  position += bytesRead;
3219
3254
  }
3220
3255
  }
3221
- var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS; exports.QvdFileReader = void 0;
3256
+ var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, COUNT_SYMBOLS_PAST; exports.QvdFileReader = void 0;
3222
3257
  var init_QvdFileReader = __esm({
3223
3258
  "src/QvdFileReader.js"() {
3224
3259
  init_QvdDataFrame();
@@ -3236,6 +3271,7 @@ var init_QvdFileReader = __esm({
3236
3271
  MAX_HEADER_SIZE = 16 * 1024 * 1024;
3237
3272
  READ_CHUNK_SIZE = 512 * 1024 * 1024;
3238
3273
  ANALYSIS_SLICE_ROWS = 65536;
3274
+ COUNT_SYMBOLS_PAST = 65536;
3239
3275
  __name(chunksFrom, "chunksFrom");
3240
3276
  exports.QvdFileReader = class {
3241
3277
  static {
@@ -3637,6 +3673,7 @@ var init_QvdFileReader = __esm({
3637
3673
  }
3638
3674
  const headerEndIndex = headerDelimiterIndex + HEADER_DELIMITER.length;
3639
3675
  const headerBuffer = this._buffer.subarray(headerBeginIndex, headerEndIndex);
3676
+ this._fieldBitMetadataValidated = false;
3640
3677
  this._header = await xml2__default.default.parseStringPromise(headerBuffer.toString(), { explicitArray: false });
3641
3678
  if (!this._header) {
3642
3679
  throw new exports.QvdParseError("The XML header could not be parsed.", {
@@ -3682,8 +3719,8 @@ var init_QvdFileReader = __esm({
3682
3719
  * @param {QvdRowWindow} window The rows of interest, as file row indices.
3683
3720
  * @param {string} stage Stage name for any error raised here.
3684
3721
  * @return {{fields: Array<any>, recordSize: number, totalRows: number, rowsToLoad: number,
3685
- * indexBuffer: Buffer}} The record geometry. `indexBuffer` starts at the window's first
3686
- * record, so the decoder always counts from zero.
3722
+ * indexBuffer: Buffer, firstRow: number}} The record geometry. `indexBuffer` starts at the
3723
+ * window's first record, file row `firstRow`, so the decoder always counts from zero.
3687
3724
  * @private
3688
3725
  */
3689
3726
  _planIndexTable(window, stage) {
@@ -3729,7 +3766,7 @@ var init_QvdFileReader = __esm({
3729
3766
  rowsToLoad === 0 || recordSize === 0 || Math.floor(indexBuffer.length / recordSize) >= rowsToLoad,
3730
3767
  `The index table holds ${Math.floor(indexBuffer.length / (recordSize || 1))} whole records but ${rowsToLoad} were validated as present.`
3731
3768
  );
3732
- return { fields, recordSize, totalRows, rowsToLoad, indexBuffer };
3769
+ return { fields, recordSize, totalRows, rowsToLoad, indexBuffer, firstRow };
3733
3770
  }
3734
3771
  /**
3735
3772
  * Analyzes the index table to determine which symbols are actually needed.
@@ -3756,6 +3793,9 @@ var init_QvdFileReader = __esm({
3756
3793
  const bitOffset = parseInt(field["BitOffset"], 10);
3757
3794
  const bitWidth = parseInt(field["BitWidth"], 10);
3758
3795
  const bias = parseInt(field["Bias"], 10);
3796
+ const length = parseInt(field["Length"], 10);
3797
+ let indexLimit = Number.isSafeInteger(length) && length >= 0 ? Math.ceil(length / 2) : Infinity;
3798
+ let counted = false;
3759
3799
  for (let first = 0; first < rowsToLoad; first += sliceRows) {
3760
3800
  const count = Math.min(sliceRows, rowsToLoad - first);
3761
3801
  decodeIndexColumn(
@@ -3768,15 +3808,53 @@ var init_QvdFileReader = __esm({
3768
3808
  column
3769
3809
  );
3770
3810
  for (let row = 0; row < count; row++) {
3771
- if (column[row] >= 0) {
3811
+ if (column[row] >= 0 && column[row] < indexLimit) {
3772
3812
  needed.add(column[row]);
3773
3813
  }
3774
3814
  }
3815
+ if (!counted && needed.size > COUNT_SYMBOLS_PAST) {
3816
+ counted = true;
3817
+ indexLimit = Math.min(indexLimit, this._countFieldSymbols(field));
3818
+ for (const index of needed) {
3819
+ if (index >= indexLimit) {
3820
+ needed.delete(index);
3821
+ }
3822
+ }
3823
+ }
3775
3824
  }
3776
3825
  this._emitProgress("symbol-analysis", position + 1, fields.length);
3777
3826
  });
3778
3827
  return symbolUsage;
3779
3828
  }
3829
+ /**
3830
+ * How many symbols a field holds, for the symbol-usage pass, which runs before the symbols are parsed.
3831
+ *
3832
+ * The count is `countFieldSymbols`, the parse itself told to decode nothing, so it is the count
3833
+ * `_parseSymbolTable` will produce and `_parseIndexTable` will check against. The field's area is
3834
+ * validated first, as `_parseSymbolTable` would, so a damaged `Offset` or `Length` is reported the
3835
+ * same way wherever it is met.
3836
+ *
3837
+ * @param {any} field The field's header.
3838
+ * @return {number} Its symbols.
3839
+ * @throws {QvdCorruptedError} If the area is not inside the symbol table, or a symbol runs past it.
3840
+ * @private
3841
+ */
3842
+ _countFieldSymbols(field) {
3843
+ assert3__default.default(
3844
+ this._buffer && this._symbolTableOffset && this._indexTableOffset,
3845
+ "The QVD file has not been read before its symbols were counted."
3846
+ );
3847
+ const symbolBuffer = this._buffer.subarray(this._symbolTableOffset, this._indexTableOffset);
3848
+ validateFieldMetadata(field, symbolBuffer.length, this._path);
3849
+ const offset = parseInt(field["Offset"], 10);
3850
+ return countFieldSymbols(
3851
+ symbolBuffer,
3852
+ offset,
3853
+ offset + parseInt(field["Length"], 10),
3854
+ field["FieldName"],
3855
+ this._path
3856
+ );
3857
+ }
3780
3858
  /**
3781
3859
  * Parses the symbol table of the QVD file. This method is part of the parsing process
3782
3860
  * and should not be called directly.
@@ -3862,12 +3940,19 @@ var init_QvdFileReader = __esm({
3862
3940
  * `base += recordSize`, so decoding rows k to k+n is a question of where the buffer slice starts
3863
3941
  * and how many iterations run. Nothing about the decoder changed to support it.
3864
3942
  *
3943
+ * Every index is checked as it is decoded. One that addresses neither a symbol of its field nor
3944
+ * NULL throws a `QvdCorruptedError` naming the file row (#125). Here and not where rows or columns
3945
+ * are built, because every read decodes through this method and a columnar read hands its codes
3946
+ * straight to the caller. Rows outside the window are not decoded, so they are not checked.
3947
+ *
3865
3948
  * @param {QvdRowWindow} window The rows to decode.
3949
+ * @throws {QvdCorruptedError} If an index in the window addresses neither a symbol nor NULL.
3866
3950
  */
3867
3951
  async _parseIndexTable(window) {
3868
- const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(window, "parseIndexTable");
3869
- this._rowsDecoded = rowsToLoad;
3870
- this._indexColumns = fields.map((field, position) => {
3952
+ const { fields, recordSize, rowsToLoad, indexBuffer, firstRow } = this._planIndexTable(window, "parseIndexTable");
3953
+ assert3__default.default(this._symbolTable, "The QVD file symbol table has not been parsed.");
3954
+ const symbolTable = this._symbolTable;
3955
+ const columns = fields.map((field, position) => {
3871
3956
  this._throwIfAborted();
3872
3957
  const column = decodeIndexColumn(
3873
3958
  indexBuffer,
@@ -3876,11 +3961,14 @@ var init_QvdFileReader = __esm({
3876
3961
  parseInt(field["BitOffset"], 10),
3877
3962
  parseInt(field["BitWidth"], 10),
3878
3963
  parseInt(field["Bias"], 10),
3879
- new Int32Array(rowsToLoad)
3964
+ new Int32Array(rowsToLoad),
3965
+ { symbolCount: symbolTable[position].numbers.length, field: field["FieldName"], file: this._path, firstRow }
3880
3966
  );
3881
3967
  this._emitProgress("index-table", position + 1, fields.length);
3882
3968
  return column;
3883
3969
  });
3970
+ this._indexColumns = columns;
3971
+ this._rowsDecoded = rowsToLoad;
3884
3972
  }
3885
3973
  /**
3886
3974
  * Reads the file's schema and header metadata, without touching the symbol or index tables.
@@ -4009,7 +4097,8 @@ var init_QvdFileReader = __esm({
4009
4097
  * `chunkSize * 2`, and why the heap it needs is twice what one chunk suggests.
4010
4098
  *
4011
4099
  * A window covering no rows yields nothing at all, rather than one empty frame - so
4012
- * `for await` over an exhausted offset does nothing, which is what a paging loop wants.
4100
+ * `for await` over an exhausted offset does nothing, which is what a paging loop wants. Its header
4101
+ * is still checked, as every read of a file's rows checks it.
4013
4102
  *
4014
4103
  * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} window
4015
4104
  * The rows to cover, in the same spellings `load()` accepts.
@@ -4027,6 +4116,10 @@ var init_QvdFileReader = __esm({
4027
4116
  const liveRows = { rows: chunkSize * 2, perChunk: 2 };
4028
4117
  const rows = normaliseWindow(window, this._path);
4029
4118
  const prepared = await this._prepare(rows, liveRows);
4119
+ if (prepared.rowsAvailable === 0) {
4120
+ this._planIndexTable({ offset: prepared.offset, limit: 0 }, "parseIndexTable");
4121
+ return;
4122
+ }
4030
4123
  for (let done = 0; done < prepared.rowsAvailable; done += chunkSize) {
4031
4124
  this._throwIfAborted();
4032
4125
  const count = Math.min(chunkSize, prepared.rowsAvailable - done);