qvdjs 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -17
- package/dist/index.cjs +869 -321
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +869 -321
- package/dist/index.js.map +1 -1
- package/package.json +15 -15
package/README.md
CHANGED
|
@@ -30,9 +30,10 @@ It parses the binary QVD format into a JavaScript object structure and back agai
|
|
|
30
30
|
callbacks for long writes. Files it produces open in Qlik Sense and QlikView.
|
|
31
31
|
- **Refusing rather than crashing.** A load too large for the process throws a catchable `QvdValidationError`
|
|
32
32
|
naming the limit it hit and a row count that would fit, instead of a `FATAL ERROR: Reached heap limit` that no
|
|
33
|
-
`try`/`catch` can intercept. The
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
`try`/`catch` can intercept. The estimate accounts for the rows and columns being materialised, not just the
|
|
34
|
+
symbol table, and the budget comes from the V8 heap ceiling and any container memory limit — the two things
|
|
35
|
+
that actually kill a process. The suggested row count is checked against the same estimate before being
|
|
36
|
+
offered, so following it works. See [QVD File Size Limitations](#qvd-file-size-limitations).
|
|
36
37
|
- **Corrupt files are detected, not silently misread.** Truncated index tables, missing header delimiters and
|
|
37
38
|
out-of-range offsets raise typed errors rather than returning short or fabricated data.
|
|
38
39
|
- **Path traversal protection by default.** File access is confined to the working directory unless you widen it,
|
|
@@ -287,11 +288,12 @@ The maximum QVD file size you can handle with qvdjs depends on several factors a
|
|
|
287
288
|
|
|
288
289
|
- **Node.js Memory Limits**: By default, Node.js limits heap memory to approximately 4GB (varies by architecture and Node.js version). The library **automatically detects your configured heap size** and scales its safety limits accordingly. You can increase heap size using the `--max-old-space-size` flag (e.g., `node --max-old-space-size=16384 script.js` for 16GB), and qvdjs will automatically allow larger files.
|
|
289
290
|
|
|
290
|
-
|
|
291
|
+
You can also adjust the `memorySafetyFactor` option (default 0.8 = 80% of the budget) to trade
|
|
292
|
+
headroom for reach:
|
|
291
293
|
|
|
292
294
|
```javascript
|
|
293
295
|
const df = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
294
|
-
memorySafetyFactor: 0.
|
|
296
|
+
memorySafetyFactor: 0.9, // Use 90% of the budget instead of the default 80%
|
|
295
297
|
});
|
|
296
298
|
```
|
|
297
299
|
|
|
@@ -326,8 +328,32 @@ The maximum QVD file size you can handle with qvdjs depends on several factors a
|
|
|
326
328
|
- **Operation Type**: Reading typically uses less memory than writing, especially when using lazy loading (`maxRows` option). Writing requires building complete symbol and index tables in memory.
|
|
327
329
|
|
|
328
330
|
- **Practical Guidance**:
|
|
329
|
-
-
|
|
330
|
-
|
|
331
|
+
- **Count cells, not megabytes.** What exhausts the heap on a full load is one array per row plus
|
|
332
|
+
one slot per cell, so a file's size on disk predicts very little. On a default 4 GB heap the
|
|
333
|
+
guard currently accepts roughly:
|
|
334
|
+
|
|
335
|
+
| Columns | Rows | Cells |
|
|
336
|
+
| ------- | ------ | ----- |
|
|
337
|
+
| 5 | 30.5 M | 153 M |
|
|
338
|
+
| 10 | 22.5 M | 225 M |
|
|
339
|
+
| 20 | 14.7 M | 295 M |
|
|
340
|
+
| 40 | 8.7 M | 349 M |
|
|
341
|
+
|
|
342
|
+
These are about 4× what they were before 2026-09-11, for two reasons: the bitwise decode
|
|
343
|
+
(#134) cut what a read actually needs by roughly 2.4×, and the guard's model was recalibrated
|
|
344
|
+
against that — it had been over-estimating a real read by 4.8×. The guard still errs high,
|
|
345
|
+
admitting a load only at 1.3–2× the heap it measurably needs, because under-estimating means
|
|
346
|
+
an uncatchable abort while over-estimating means a catchable refusal. Doubling the heap with
|
|
347
|
+
`--max-old-space-size` roughly doubles these numbers.
|
|
348
|
+
|
|
349
|
+
- **A columnar read is not bounded by any of this.** `QvdColumnTable.fromQvd()` stores codes in
|
|
350
|
+
typed arrays, which live outside the V8 heap, so the heap ceiling barely applies: the 38 MB
|
|
351
|
+
taxi fixture reads columnar in a 15 MB heap, where the same file as rows needs 367 MB. If you
|
|
352
|
+
are hitting the table above, reading the columns you need is usually the answer rather than a
|
|
353
|
+
bigger heap.
|
|
354
|
+
|
|
355
|
+
- High-cardinality data (unique values in most rows) also loads the symbol table, so the ceiling
|
|
356
|
+
drops further — subtract about 90 MB of heap per million distinct values
|
|
331
357
|
- **With increased heap** (e.g., 16GB+), you can handle proportionally larger files by adjusting `memorySafetyFactor`
|
|
332
358
|
- Use lazy loading (`maxRows` option) when possible to reduce memory footprint when reading
|
|
333
359
|
- Monitor memory usage with tools like `process.memoryUsage()` for your specific use cases
|
|
@@ -432,7 +458,21 @@ These optimizations can reduce write times by 80-90% for large datasets (100K+ r
|
|
|
432
458
|
|
|
433
459
|
### Working with Metadata
|
|
434
460
|
|
|
435
|
-
|
|
461
|
+
To inspect a file's schema without reading its data, use `readMetadata`. It stops at the XML
|
|
462
|
+
header, so it costs the same for a 40 MB file as for a 40 GB one:
|
|
463
|
+
|
|
464
|
+
```javascript
|
|
465
|
+
import {QvdDataFrame} from 'qvdjs';
|
|
466
|
+
|
|
467
|
+
const {columns, rowCount, fields} = await QvdDataFrame.readMetadata('path/to/file.qvd');
|
|
468
|
+
|
|
469
|
+
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Note that `fromQvd(path, {maxRows: 0})` is not equivalent — it loads no rows, but still reads and
|
|
473
|
+
parses the whole symbol table.
|
|
474
|
+
|
|
475
|
+
For metadata alongside the data, every accessor below is available on a loaded frame:
|
|
436
476
|
|
|
437
477
|
```javascript
|
|
438
478
|
import {QvdDataFrame} from 'qvdjs';
|
|
@@ -577,13 +617,13 @@ try {
|
|
|
577
617
|
|
|
578
618
|
Honest boundaries rather than an issue list — these are the ones that change what you can do.
|
|
579
619
|
|
|
580
|
-
| Limitation
|
|
581
|
-
|
|
|
582
|
-
| **Full loads stop at 2 GiB**
|
|
583
|
-
| **
|
|
584
|
-
| **Dual values are not round-tripped as pairs**
|
|
585
|
-
| **Writes are not atomic**
|
|
586
|
-
| **Writer input is not validated**
|
|
620
|
+
| Limitation | What it means in practice | Tracked as |
|
|
621
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
622
|
+
| **Full loads stop at 2 GiB** | Use `maxRows` above that; the lazy path reads in chunks and has no such ceiling. Failure is a raw Node `RangeError`, not a `QvdError`. | [#122](https://github.com/ptarmiganlabs/qvdjs/issues/122) |
|
|
623
|
+
| **A full load materialises every row in memory** | Rows are stored as one JavaScript array per row with a boxed value per cell, measured at roughly 11 bytes per cell. That, not file size, sets the ceiling in the table above. `maxRows` reads a prefix, and `QvdColumnTable` avoids row materialisation entirely — but there is still no streaming or chunked read of an arbitrary window. | [#140](https://github.com/ptarmiganlabs/qvdjs/issues/140) |
|
|
624
|
+
| **Dual values are not round-tripped as pairs** | Qlik stores dates and timestamps as a number plus a display string. A data frame exposes one of the two, so a read-modify-write does not reproduce the original dual — a `BirthDate` field comes back as `24205`, not as a formatted date. | [#138](https://github.com/ptarmiganlabs/qvdjs/issues/138) |
|
|
625
|
+
| **Writes are not atomic** | `toQvd()` writes in place. A failure part-way through, or two writers targeting one path, leaves the previous file damaged rather than intact. Write to a temporary path and rename if that matters. | [#129](https://github.com/ptarmiganlabs/qvdjs/issues/129) |
|
|
626
|
+
| **Writer input is not validated** | Unsupported value types can produce a raw `TypeError`, or a file that is written but does not read back as intended. Feed `fromDict()` numbers, strings and `null`. | [#130](https://github.com/ptarmiganlabs/qvdjs/issues/130) |
|
|
587
627
|
|
|
588
628
|
Numeric-looking strings are currently coerced to numbers on read, which is being reconsidered as a breaking
|
|
589
629
|
change in [#120](https://github.com/ptarmiganlabs/qvdjs/issues/120). Empty and whitespace-only strings are _not_
|
|
@@ -722,7 +762,7 @@ to a `QvdDataFrame` instance.
|
|
|
722
762
|
- `options` (object, optional): Loading options
|
|
723
763
|
- `maxRows` (number, optional): Maximum number of rows to load. If not specified, all rows are loaded. This is useful for loading only a subset of data from large QVD files to improve performance and reduce memory usage.
|
|
724
764
|
- `allowedDir` (string, optional): Base directory for file access validation. Defaults to current working directory (CWD). The file path must resolve to a location within this directory to prevent path traversal attacks. Set to a specific directory in production environments with user-provided paths.
|
|
725
|
-
- `memorySafetyFactor` (number, optional): Fraction (0.0-1.0) of the memory budget a load may use. Default is 0.
|
|
765
|
+
- `memorySafetyFactor` (number, optional): Fraction (0.0-1.0) of the memory budget a load may use. Default is 0.8 (80%). The budget is the smaller of the V8 heap limit and any container memory limit; see [QVD File Size Limitations](#qvd-file-size-limitations). Increase this (e.g., to 0.5 or 0.7) when running with a larger heap via `--max-old-space-size`. **`0` disables the memory check entirely.**
|
|
726
766
|
- `symbolFilteringThreshold` (number, optional): Symbol table size, in bytes, above which a lazy load switches to the two-pass filtering path. Defaults to 50 MB, the point where the extra analysis pass pays for itself. Lower it to use filtering on smaller files, raise it to keep the simpler single-pass read for longer.
|
|
727
767
|
|
|
728
768
|
**Example:**
|
|
@@ -741,7 +781,7 @@ const dfSecure = await QvdDataFrame.fromQvd('reports/sales.qvd', {
|
|
|
741
781
|
|
|
742
782
|
// Load with increased memory usage for large heap configurations
|
|
743
783
|
const dfLarge = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
744
|
-
memorySafetyFactor: 0.
|
|
784
|
+
memorySafetyFactor: 0.9, // Use 90% of the budget instead of the default 80%
|
|
745
785
|
});
|
|
746
786
|
|
|
747
787
|
// Manage memory yourself: skip the check entirely
|
|
@@ -753,6 +793,124 @@ console.log(preview.loadStats.symbolFiltering); // true when the two-pass path r
|
|
|
753
793
|
console.log(preview.loadStats.symbolsKept); // how many symbols it kept
|
|
754
794
|
```
|
|
755
795
|
|
|
796
|
+
#### `static readMetadata(path: string, options?: object): Promise<object>`
|
|
797
|
+
|
|
798
|
+
Reads a QVD file's schema and header metadata without reading its data. The cost is the same
|
|
799
|
+
whatever the file's size, because it stops at the XML header and never touches the symbol or
|
|
800
|
+
index tables.
|
|
801
|
+
|
|
802
|
+
This is not the same as `fromQvd(path, {maxRows: 0})`. That loads no rows but still reads and
|
|
803
|
+
parses the entire symbol table — 0.4 MB on a 38 MB file, but 15 MB on a high-cardinality one, and
|
|
804
|
+
it grows with the data. Measured on a 200,000-row file where every value is distinct,
|
|
805
|
+
`readMetadata` is **352× faster**, and unlike `{maxRows: 0}` it does not get slower as the file
|
|
806
|
+
grows.
|
|
807
|
+
|
|
808
|
+
**Parameters:**
|
|
809
|
+
|
|
810
|
+
- `path` (string): The path to the QVD file.
|
|
811
|
+
- `options` (object, optional):
|
|
812
|
+
- `allowedDir` (string, optional): Base directory for file access validation, applied exactly as
|
|
813
|
+
it is for `fromQvd`.
|
|
814
|
+
|
|
815
|
+
**Returns** a plain object — deliberately not a `QvdDataFrame`, since one with `data: []` would be
|
|
816
|
+
indistinguishable from an empty file at the call site:
|
|
817
|
+
|
|
818
|
+
| Property | Type | Description |
|
|
819
|
+
| -------------- | ---------- | ----------------------------------------------------------------------------------- |
|
|
820
|
+
| `columns` | `string[]` | Field names, in file order. |
|
|
821
|
+
| `rowCount` | `number` | Rows the **file** declares. Nothing was loaded; this is not a count of rows read. |
|
|
822
|
+
| `columnCount` | `number` | Number of fields. |
|
|
823
|
+
| `fields` | `object[]` | Per-field metadata, same shape and order as `getFieldMetadata()` on a loaded frame. |
|
|
824
|
+
| `fileMetadata` | `object` | Same shape as the `fileMetadata` accessor on a loaded frame. |
|
|
825
|
+
| `metadata` | `object` | The raw `QvdTableHeader`, as `metadata` gives it. |
|
|
826
|
+
|
|
827
|
+
**Example:**
|
|
828
|
+
|
|
829
|
+
```javascript
|
|
830
|
+
// What is in this file, without reading any of it
|
|
831
|
+
const {columns, rowCount, fields} = await QvdDataFrame.readMetadata('sales.qvd');
|
|
832
|
+
|
|
833
|
+
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
834
|
+
|
|
835
|
+
for (const field of fields) {
|
|
836
|
+
console.log(`${field.fieldName}: ${field.noOfSymbols} distinct values`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Decide whether it is worth loading at all
|
|
840
|
+
if (rowCount < 1_000_000) {
|
|
841
|
+
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
### QvdColumnTable
|
|
846
|
+
|
|
847
|
+
`QvdColumnTable` reads a QVD **as columns instead of rows**. It uses the same decoder and the
|
|
848
|
+
same symbol resolution as `QvdDataFrame.fromQvd` — it simply stops before building rows, and
|
|
849
|
+
keeps what the decoder already produced: one `Int32Array` of codes per field, and one resolved
|
|
850
|
+
value per _distinct_ symbol.
|
|
851
|
+
|
|
852
|
+
Measured on the bundled 1.7 M × 20 taxi fixture, each in its own process so only one
|
|
853
|
+
representation is alive:
|
|
854
|
+
|
|
855
|
+
| | Live memory | Sum one column |
|
|
856
|
+
| ------------------------ | ----------- | -------------- |
|
|
857
|
+
| `QvdDataFrame.fromQvd` | 385 MiB | 26.8 ms |
|
|
858
|
+
| `QvdColumnTable.fromQvd` | **141 MiB** | **3.6 ms** |
|
|
859
|
+
|
|
860
|
+
Use it when you want to scan or aggregate a few columns of a large file. Use `QvdDataFrame` when
|
|
861
|
+
you want rows. It does not convert to a data frame, deliberately — holding both representations
|
|
862
|
+
is the one configuration in which this costs more than it saves, and re-reading a file as rows
|
|
863
|
+
costs no more than reading it as rows always did.
|
|
864
|
+
|
|
865
|
+
```javascript
|
|
866
|
+
import {QvdColumnTable} from 'qvdjs';
|
|
867
|
+
|
|
868
|
+
const table = await QvdColumnTable.fromQvd('trips.qvd');
|
|
869
|
+
const fare = table.column('fare');
|
|
870
|
+
|
|
871
|
+
// The fastest scan: both sides are contiguous typed arrays and the dictionary fits in cache.
|
|
872
|
+
const codes = fare.codes;
|
|
873
|
+
const values = fare.numericSymbols();
|
|
874
|
+
let total = 0;
|
|
875
|
+
|
|
876
|
+
for (let row = 0; row < codes.length; row++) {
|
|
877
|
+
const code = codes[row];
|
|
878
|
+
if (code >= 0) {
|
|
879
|
+
const value = values[code];
|
|
880
|
+
if (!Number.isNaN(value)) total += value;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Or, more simply
|
|
885
|
+
for (const value of fare) {
|
|
886
|
+
/* number | string | null */
|
|
887
|
+
}
|
|
888
|
+
console.log(fare.at(0), fare.toArray().length);
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
**`QvdColumnTable`** — `static fromQvd(path, options)` (same options as `QvdDataFrame.fromQvd`),
|
|
892
|
+
`column(name)`, `columns`, `rowCount`, `shape`, `metadata`, `loadStats`.
|
|
893
|
+
|
|
894
|
+
**`QvdColumn`** — what `column(name)` returns, frozen:
|
|
895
|
+
|
|
896
|
+
| Member | Type | Description |
|
|
897
|
+
| -------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
898
|
+
| `name` | `string` | The field name. |
|
|
899
|
+
| `length` | `number` | Rows in the column. |
|
|
900
|
+
| `codes` | `Int32Array` | One stored index per row. **Negative means NULL.** The table's own array — treat as read-only. |
|
|
901
|
+
| `symbols` | `ReadonlyArray<any>` | The distinct values, indexed by the codes. One entry per distinct value, not per row. |
|
|
902
|
+
| `at(row)` | `any` | The value of one row, or `null`. |
|
|
903
|
+
| `[Symbol.iterator]` | | Iterates values without materialising the column. |
|
|
904
|
+
| `toArray()` | `Array<any>` | Lossless; cell-for-cell what `data[row][column]` holds. Caller-owned. |
|
|
905
|
+
| `numericSymbols()` | `Float64Array` | The **dictionary** as numbers, NaN for non-numeric. One entry per distinct value — 17 KB for a 1.7 M-row column. |
|
|
906
|
+
| `toFloat64Array(options?)` | `Float64Array` | One number **per row**. Lossy, so it throws on a non-numeric value unless you pass `{onNonNumeric: 'nan'}`. |
|
|
907
|
+
|
|
908
|
+
`toFloat64Array` refuses by default rather than writing NaN because on real QVDs the lossy case
|
|
909
|
+
is common, not exceptional: in the bundled taxi fixture **no column is strictly numeric** —
|
|
910
|
+
`dropoff_census_tract` is 43 % empty strings, and four columns are 100 % strings. Silently
|
|
911
|
+
turning two fifths of a column into NaN would erase Qlik's distinction between a blank and a
|
|
912
|
+
number. `numericSymbols()` is the cheap conversion; `toFloat64Array()` is the expensive one.
|
|
913
|
+
|
|
756
914
|
#### `static fromDict(dict: object): Promise<QvdDataFrame>`
|
|
757
915
|
|
|
758
916
|
The static method `QvdDataFrame.fromDict` constructs a data frame from a dictionary. The dictionary must contain the columns and
|