qvdjs 0.11.0 → 1.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 +141 -1318
- package/dist/index.cjs +1969 -572
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1967 -574
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -7,171 +7,57 @@
|
|
|
7
7
|
|
|
8
8
|
# qvdjs
|
|
9
9
|
|
|
10
|
-
>
|
|
11
|
-
|
|
12
|
-
## ⚠️ Important Disclaimer
|
|
13
|
-
|
|
14
|
-
**This library is based on reverse engineering of the QVD file format**, as the format is not publicly documented by Qlik. While extensive effort has been made to understand and implement the format correctly, there may be incorrect assumptions or interpretations of the file structure.
|
|
15
|
-
|
|
16
|
-
**Comprehensive testing has been performed** to ensure that QVD files created or modified by qvdjs are valid and can be loaded by Qlik Sense and QlikView without errors. However, users should:
|
|
17
|
-
|
|
18
|
-
- Test thoroughly with their specific use cases
|
|
19
|
-
- Validate output files in their Qlik environment
|
|
20
|
-
- Report any issues or inconsistencies discovered
|
|
21
|
-
|
|
22
|
-
The library works with real-world QVD files and maintains compatibility with Qlik products, but it is an independent, community-driven implementation.
|
|
23
|
-
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
The _qvdjs_ library provides a simple API for reading and writing Qlik View Data (QVD) files in JavaScript.
|
|
27
|
-
It parses the binary QVD format into a JavaScript object structure and back again, and is written for Node.js
|
|
28
|
-
(20.10 or newer) exclusively.
|
|
29
|
-
|
|
30
|
-
**What it does well:**
|
|
31
|
-
|
|
32
|
-
- **Large files without loading them.** Pass `maxRows` and the library reads only the header, the symbols those
|
|
33
|
-
rows actually reference, and that slice of the index table. On a file whose symbol table is large because its
|
|
34
|
-
fields hold mostly unique values, this is the difference between minutes and under a second — and it is the
|
|
35
|
-
only way to read a file above 2 GiB at all. See [Lazy Loading](#lazy-loading).
|
|
36
|
-
- **Writing at real row counts.** `toQvd()` handles hundreds of thousands of rows, with optional progress
|
|
37
|
-
callbacks for long writes. Files it produces open in Qlik Sense and QlikView.
|
|
38
|
-
- **Refusing rather than crashing.** A load too large for the process throws a catchable `QvdValidationError`
|
|
39
|
-
naming the limit it hit and a row count that would fit, instead of a `FATAL ERROR: Reached heap limit` that no
|
|
40
|
-
`try`/`catch` can intercept. The estimate accounts for the rows and columns being materialised, not just the
|
|
41
|
-
symbol table, and the budget comes from the V8 heap ceiling and any container memory limit — the two things
|
|
42
|
-
that actually kill a process. The suggested row count is checked against the same estimate before being
|
|
43
|
-
offered, so following it works. See [QVD File Size Limitations](#qvd-file-size-limitations).
|
|
44
|
-
- **Corrupt files are detected, not silently misread.** Truncated index tables, missing header delimiters and
|
|
45
|
-
out-of-range offsets raise typed errors rather than returning short or fabricated data.
|
|
46
|
-
- **Path traversal protection by default.** File access is confined to the working directory unless you widen it,
|
|
47
|
-
and containment is decided by the filesystem — symlinks are resolved, so a link inside the allowed directory
|
|
48
|
-
pointing outside it is refused. See [Security Considerations](#security-considerations).
|
|
49
|
-
|
|
50
|
-
**What it does not do yet** is worth knowing before you start: see
|
|
51
|
-
[Known limitations](#known-limitations).
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
- [qvdjs](#qvdjs)
|
|
56
|
-
- [⚠️ Important Disclaimer](#️-important-disclaimer)
|
|
57
|
-
- [Install](#install)
|
|
58
|
-
- [Usage](#usage)
|
|
59
|
-
- [Four ways to open a file](#four-ways-to-open-a-file)
|
|
60
|
-
- [Lazy Loading](#lazy-loading)
|
|
61
|
-
- [Important: Symbol Table and High-Cardinality Fields](#important-symbol-table-and-high-cardinality-fields)
|
|
62
|
-
- [Performance Optimizations](#performance-optimizations)
|
|
63
|
-
- [QVD File Size Limitations](#qvd-file-size-limitations)
|
|
64
|
-
- [The 2 GiB boundary](#the-2-gib-boundary)
|
|
65
|
-
- [Why Safety Limits Exist](#why-safety-limits-exist)
|
|
66
|
-
- [Writing QVD files](#writing-qvd-files)
|
|
67
|
-
- [Progress tracking](#progress-tracking)
|
|
68
|
-
- [Working with Metadata](#working-with-metadata)
|
|
69
|
-
- [Security Considerations](#security-considerations)
|
|
70
|
-
- [Known limitations](#known-limitations)
|
|
71
|
-
- [QVD File Format](#qvd-file-format)
|
|
72
|
-
- [XML Header](#xml-header)
|
|
73
|
-
- [Symbol Table](#symbol-table)
|
|
74
|
-
- [Index Table](#index-table)
|
|
75
|
-
- [Empty QVD Files](#empty-qvd-files)
|
|
76
|
-
- [API Documentation](#api-documentation)
|
|
77
|
-
- [QvdDataFrame](#qvddataframe)
|
|
78
|
-
- [`static fromQvd(path: string, options?: object): Promise<QvdDataFrame>`](#static-fromqvdpath-string-options-object-promiseqvddataframe)
|
|
79
|
-
- [Reading part of a file](#reading-part-of-a-file)
|
|
80
|
-
- [`maxRows` and `limit` are one option](#maxrows-and-limit-are-one-option)
|
|
81
|
-
- [`fields` skips the columns you did not ask for](#fields-skips-the-columns-you-did-not-ask-for)
|
|
82
|
-
- [`static iterate(path: string, options?: object): AsyncGenerator<QvdDataFrame>`](#static-iteratepath-string-options-object-asyncgeneratorqvddataframe)
|
|
83
|
-
- [Read progress and cancellation](#read-progress-and-cancellation)
|
|
84
|
-
- [`static readMetadata(path: string, options?: object): Promise<object>`](#static-readmetadatapath-string-options-object-promiseobject)
|
|
85
|
-
- [`static fromDict(dict: object): Promise<QvdDataFrame>`](#static-fromdictdict-object-promiseqvddataframe)
|
|
86
|
-
- [`head(n: number): QvdDataFrame`](#headn-number-qvddataframe)
|
|
87
|
-
- [`tail(n: number): QvdDataFrame`](#tailn-number-qvddataframe)
|
|
88
|
-
- [`rows(...args: number): QvdDataFrame`](#rowsargs-number-qvddataframe)
|
|
89
|
-
- [`at(row: number, column: string): any`](#atrow-number-column-string-any)
|
|
90
|
-
- [`select(...args: string): QvdDataFrame`](#selectargs-string-qvddataframe)
|
|
91
|
-
- [`toDict(): Promise<object>`](#todict-promiseobject)
|
|
92
|
-
- [`toQvd(path: string, options?: object): Promise<void>`](#toqvdpath-string-options-object-promisevoid)
|
|
93
|
-
- [`getFieldMetadata(fieldName: string): object | null`](#getfieldmetadatafieldname-string-object--null)
|
|
94
|
-
- [`getAllFieldMetadata(): object[]`](#getallfieldmetadata-object)
|
|
95
|
-
- [`setFileMetadata(metadata: object): void`](#setfilemetadatametadata-object-void)
|
|
96
|
-
- [`setFieldMetadata(fieldName: string, metadata: object): void`](#setfieldmetadatafieldname-string-metadata-object-void)
|
|
97
|
-
- [QvdColumnTable](#qvdcolumntable)
|
|
98
|
-
- [The low-level exports](#the-low-level-exports)
|
|
99
|
-
- [Documentation](#documentation)
|
|
100
|
-
- [For Users](#for-users)
|
|
101
|
-
- [For Contributors](#for-contributors)
|
|
102
|
-
- [Quick Links by Task](#quick-links-by-task)
|
|
103
|
-
- [Testing](#testing)
|
|
104
|
-
- [Running Tests](#running-tests)
|
|
105
|
-
- [Contributing](#contributing)
|
|
106
|
-
- [Contributors](#contributors)
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
## Install
|
|
111
|
-
|
|
112
|
-
_qvdjs_ is a Node.js module available through [npm](https://www.npmjs.com/). The recommended way to install and maintain _qvdjs_ as a dependency is through the Node.js Package Manager (NPM).
|
|
113
|
-
Before installing this library, download and install Node.js.
|
|
114
|
-
|
|
115
|
-
You can get _qvdjs_ using the following command:
|
|
10
|
+
> Read and write Qlik Sense and QlikView (QVD) files from Node.js
|
|
116
11
|
|
|
117
12
|
```bash
|
|
118
|
-
npm install qvdjs
|
|
13
|
+
npm install qvdjs
|
|
119
14
|
```
|
|
120
15
|
|
|
121
|
-
**Module Format Support:**
|
|
122
|
-
This library is published as a **dual ESM/CJS package**, providing full compatibility with both modern ES modules and traditional CommonJS environments:
|
|
123
|
-
|
|
124
|
-
- ✅ **ESM (ES Modules)**: Native `import` statements in Node.js and modern bundlers
|
|
125
|
-
- ✅ **CommonJS**: Traditional `require()` for compatibility with older Node.js projects
|
|
126
|
-
- ✅ **Bundlers**: Works with Webpack, Vite, Rollup, esbuild, and other modern build tools
|
|
127
|
-
|
|
128
|
-
**Usage Examples:**
|
|
129
|
-
|
|
130
16
|
```javascript
|
|
131
|
-
// ESM (ES Modules) - Modern Node.js and TypeScript
|
|
132
17
|
import {QvdDataFrame} from 'qvdjs';
|
|
133
18
|
|
|
134
|
-
|
|
135
|
-
|
|
19
|
+
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
20
|
+
console.log(df.shape); // [ 1705805, 20 ]
|
|
21
|
+
console.log(df.head(5));
|
|
136
22
|
```
|
|
137
23
|
|
|
138
|
-
|
|
24
|
+
No Qlik installation, no ODBC driver, no running engine — just the file. Node 20.10 or newer,
|
|
25
|
+
published as a dual ESM/CommonJS package, MIT licensed, one runtime dependency.
|
|
139
26
|
|
|
140
|
-
|
|
27
|
+
📖 **[Full documentation at qvdjs.ptarmiganlabs.com](https://qvdjs.ptarmiganlabs.com)** — guides,
|
|
28
|
+
API reference, the QVD format explained, measured performance, and troubleshooting.
|
|
141
29
|
|
|
142
|
-
|
|
30
|
+
## ⚠️ This library is based on reverse engineering
|
|
143
31
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const df = await QvdDataFrame.fromQvd('path/to/file.qvd');
|
|
148
|
-
console.log(df.head(5));
|
|
149
|
-
```
|
|
32
|
+
Qlik does not publish the QVD format. Everything qvdjs knows about it was worked out by reading
|
|
33
|
+
files Qlik produced, and validated by feeding its own output back to Qlik Sense and QlikView.
|
|
150
34
|
|
|
151
|
-
|
|
152
|
-
|
|
35
|
+
That works well enough to be useful, and is not the same as being exact. **Test with your own
|
|
36
|
+
files, and validate output in your own Qlik environment** before relying on it. Reading any bundled
|
|
37
|
+
Qlik file and writing it back reproduces its symbol table byte for byte, whichever way it was read; a
|
|
38
|
+
value you change is written as Qlik stores that kind of value. Where that boundary lies is the first
|
|
39
|
+
thing worth reading:
|
|
40
|
+
[What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v1.0/overview/what-a-round-trip-preserves/).
|
|
153
41
|
|
|
154
|
-
|
|
42
|
+
## Four ways to open a file
|
|
155
43
|
|
|
156
|
-
`fromQvd` is the general one, and often not the one you want. A QVD is an XML header, then a
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
holds.
|
|
160
|
-
both.
|
|
44
|
+
`fromQvd` is the general one, and often not the one you want. A QVD is an XML header, then a symbol
|
|
45
|
+
table holding every distinct value, then a bit-packed index table of one code per cell. Two things
|
|
46
|
+
separate the four calls: how far into the file a read has to go, and how much of it the read then
|
|
47
|
+
holds.
|
|
161
48
|
|
|
162
|
-
| You want | Call | How far it reads, and what it holds
|
|
163
|
-
| ---------------------------------------- | ----------------------------------------- |
|
|
164
|
-
| Rows, to index, iterate or write back | `QvdDataFrame.fromQvd(path)` | Everything, and materialises every row
|
|
165
|
-
| Rows from a file too large to hold | `QvdDataFrame.iterate(path, {chunkSize})` | Everything, but holds two chunks
|
|
166
|
-
| A few columns of a large file | `QvdColumnTable.fromQvd(path)` | Everything, but stops before building rows — 141 MiB against 385 MiB
|
|
167
|
-
| Only the schema: names, row count, types | `QvdDataFrame.readMetadata(path)` | The header alone. Constant cost, whatever the file's size
|
|
49
|
+
| You want | Call | How far it reads, and what it holds |
|
|
50
|
+
| ---------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------- |
|
|
51
|
+
| Rows, to index, iterate or write back | `QvdDataFrame.fromQvd(path)` | Everything, and materialises every row |
|
|
52
|
+
| Rows from a file too large to hold | `QvdDataFrame.iterate(path, {chunkSize})` | Everything, but holds two chunks — a 96 MB heap against 512 MB |
|
|
53
|
+
| A few columns of a large file | `QvdColumnTable.fromQvd(path)` | Everything, but stops before building rows — 141 MiB against 385 MiB |
|
|
54
|
+
| Only the schema: names, row count, types | `QvdDataFrame.readMetadata(path)` | The header alone. Constant cost, whatever the file's size |
|
|
168
55
|
|
|
169
56
|
```javascript
|
|
170
57
|
import {QvdDataFrame, QvdColumnTable} from 'qvdjs';
|
|
171
58
|
|
|
172
59
|
// What is in this file? Costs the same whether it is 20 KB or 20 GB.
|
|
173
60
|
const {columns, rowCount} = await QvdDataFrame.readMetadata('sales.qvd');
|
|
174
|
-
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
175
61
|
|
|
176
62
|
// Sum one column without ever building a row.
|
|
177
63
|
const table = await QvdColumnTable.fromQvd('sales.qvd');
|
|
@@ -187,1248 +73,185 @@ for await (const chunk of QvdDataFrame.iterate('sales.qvd', {chunkSize: 50_000})
|
|
|
187
73
|
|
|
188
74
|
// Rows, when you want rows.
|
|
189
75
|
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
190
|
-
console.log(df.head(5));
|
|
191
76
|
```
|
|
192
77
|
|
|
193
|
-
The three that read data take the same options — `offset`, `limit` (or `maxRows`), `fields`,
|
|
194
|
-
`onProgress` and `signal` — because they are three answers about the same file rather than three
|
|
195
|
-
features. See [Reading part of a file](#reading-part-of-a-file). `readMetadata` shares
|
|
196
|
-
`allowedDir`, `onProgress` and `signal`, and ignores the window and field options — a read that
|
|
197
|
-
stops at the XML header has no rows to window and no field's symbols to skip.
|
|
198
|
-
|
|
199
78
|
Reaching for `fromQvd` when you wanted one of the other three is the common mistake, and
|
|
200
|
-
`fromQvd(path, {maxRows: 0})` is not a substitute for `readMetadata`: it loads no rows but still
|
|
201
|
-
|
|
202
|
-
for the same reason. See [QvdColumnTable](#qvdcolumntable) and the
|
|
203
|
-
[API Documentation](#api-documentation) for the details and the measurements behind the table
|
|
204
|
-
above.
|
|
205
|
-
|
|
206
|
-
### Lazy Loading
|
|
207
|
-
|
|
208
|
-
For large QVD files, you can load only a specific number of rows to improve performance and reduce memory usage. The library implements **lazy loading** - it reads only the necessary portions of the file from disk, not the entire file.
|
|
209
|
-
|
|
210
|
-
```javascript
|
|
211
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
212
|
-
|
|
213
|
-
// Load only the first 1000 rows
|
|
214
|
-
const df = await QvdDataFrame.fromQvd('path/to/file.qvd', {maxRows: 1000});
|
|
215
|
-
console.log(df.shape); // [1000, numberOfColumns]
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
**How it works:**
|
|
219
|
-
|
|
220
|
-
- The library reads only the header, symbol table, and the first N rows from the index table
|
|
221
|
-
- This provides significant memory savings and faster loading times for large files
|
|
222
|
-
|
|
223
|
-
#### Important: Symbol Table and High-Cardinality Fields
|
|
224
|
-
|
|
225
|
-
The QVD format stores data in two parts:
|
|
226
|
-
|
|
227
|
-
1. **Symbol table**: Contains every unique value for every field. It has to be _scanned_ in full, because a
|
|
228
|
-
symbol's length is only known once the previous one has been read. Above `symbolFilteringThreshold` — 50 MB
|
|
229
|
-
by default — a bounded read also parses only the symbols the requested rows actually reference and steps
|
|
230
|
-
over the rest. Scanning is cheap; parsing is what costs memory. **Below that threshold every symbol is
|
|
231
|
-
parsed**, however few rows you asked for, because the extra analysis pass costs more than it saves on a
|
|
232
|
-
small table.
|
|
233
|
-
2. **Index table**: Contains row-by-row indices into the symbol table (only the requested rows are read)
|
|
234
|
-
|
|
235
|
-
⚠️ **Performance Impact of High-Cardinality Fields:**
|
|
236
|
-
|
|
237
|
-
If your QVD file contains fields with many unique values (high cardinality), such as:
|
|
238
|
-
|
|
239
|
-
- Unique IDs (OrderID, TransactionID, UUID)
|
|
240
|
-
- Timestamps with millisecond precision
|
|
241
|
-
- Unique text fields
|
|
242
|
-
|
|
243
|
-
The symbol table then becomes very large, and has to be scanned end to end even when using `maxRows`. Once it
|
|
244
|
-
passes `symbolFilteringThreshold`, parsing is skipped for symbols the requested rows do not use, so the cost
|
|
245
|
-
is I/O rather than memory:
|
|
246
|
-
|
|
247
|
-
- **Small symbol table** (fields with reusable values): Fast loading regardless of file size
|
|
248
|
-
- **Large symbol table** (fields with unique values per row): Slower, because the scan is proportional to the
|
|
249
|
-
symbol table's size — but once filtering engages, memory scales with the rows you asked for rather than with
|
|
250
|
-
the file's cardinality. Under the threshold it still scales with the file, which is the case the default is
|
|
251
|
-
chosen for: a table that small costs little to parse whole.
|
|
252
|
-
|
|
253
|
-
You can see what happened on any load through `loadStats` (see [QvdDataFrame](#qvddataframe)):
|
|
254
|
-
|
|
255
|
-
```javascript
|
|
256
|
-
// This fixture's symbol table is 0.44 MB, well under the 50 MB default, so filtering does not
|
|
257
|
-
// engage on its own — the threshold is lowered here to show the path. See below.
|
|
258
|
-
const df = await QvdDataFrame.fromQvd('large.qvd', {maxRows: 1000, symbolFilteringThreshold: 0});
|
|
259
|
-
console.log(df.loadStats);
|
|
260
|
-
// { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000, offset: 0,
|
|
261
|
-
// symbolFiltering: true, symbolsKept: 3898 }
|
|
262
|
-
//
|
|
263
|
-
// 3,898 symbols parsed for 1,000 rows, out of a file holding 1.7 million.
|
|
264
|
-
// Without symbolFilteringThreshold: 0 the same call reports symbolFiltering: false,
|
|
265
|
-
// symbolsKept: null, and parses every symbol in the file.
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
To check your QVD's symbol table size, look at the `NoOfSymbols` in field metadata - values close to the total row count indicate high cardinality.
|
|
269
|
-
|
|
270
|
-
**When lazy loading works best:**
|
|
271
|
-
|
|
272
|
-
- Previewing data from very large QVD files without loading the entire file into memory
|
|
273
|
-
- Files where most fields have reusable values (low cardinality)
|
|
274
|
-
- Data exploration and schema inspection of large datasets
|
|
275
|
-
- Faster loading times when you only need a subset of the data
|
|
276
|
-
|
|
277
|
-
### Performance Optimizations
|
|
278
|
-
|
|
279
|
-
The library includes intelligent symbol table parsing that dramatically improves performance when loading partial data with `maxRows`:
|
|
280
|
-
|
|
281
|
-
**Smart Symbol Loading:**
|
|
282
|
-
|
|
283
|
-
When you specify `maxRows`, the library:
|
|
284
|
-
|
|
285
|
-
1. Analyzes which symbols are actually needed for the requested rows
|
|
286
|
-
2. Parses **only** those symbols from the symbol table
|
|
287
|
-
3. Skips parsing unused symbols entirely (not just filtering after parsing)
|
|
288
|
-
|
|
289
|
-
This matters most on files whose symbol table is large because most values are unique — exactly the files where
|
|
290
|
-
a naive `maxRows` would still pay for the whole table. Compared to parsing every symbol regardless of `maxRows`,
|
|
291
|
-
loading a few thousand rows from a multi-million-row file has been measured at roughly an order of magnitude
|
|
292
|
-
faster and an order of magnitude smaller in peak memory.
|
|
293
|
-
|
|
294
|
-
The saving is visible on any file through `loadStats`. On a bundled 1.7-million-row fixture, asking for 1,000
|
|
295
|
-
rows parses 3,898 symbols:
|
|
296
|
-
|
|
297
|
-
```javascript
|
|
298
|
-
const df = await QvdDataFrame.fromQvd('chicago_taxi_rides_2016_01.qvd', {
|
|
299
|
-
maxRows: 1000,
|
|
300
|
-
symbolFilteringThreshold: 0, // this fixture is well under the 50 MB default
|
|
301
|
-
});
|
|
302
|
-
console.log(df.loadStats);
|
|
303
|
-
// { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
|
|
304
|
-
// symbolFiltering: true, symbolsKept: 3898 }
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
The optimisation engages automatically once the symbol table passes `symbolFilteringThreshold`, which defaults
|
|
308
|
-
to 50 MB — the point where the extra analysis pass pays for itself. The example lowers it so the path can be
|
|
309
|
-
demonstrated on a small bundled fixture; on a real high-cardinality file it engages on its own.
|
|
310
|
-
|
|
311
|
-
**Key Benefits:**
|
|
312
|
-
|
|
313
|
-
- Much faster previews of large QVD files
|
|
314
|
-
- Lower memory footprint for data exploration
|
|
315
|
-
- Efficient handling of files with large symbol tables
|
|
316
|
-
- Automatic optimization - no configuration needed
|
|
317
|
-
|
|
318
|
-
This optimization is particularly effective for files with many unique values (high cardinality) where the symbol table is large but you only need to preview a small portion of the data.
|
|
319
|
-
|
|
320
|
-
### QVD File Size Limitations
|
|
321
|
-
|
|
322
|
-
#### The 2 GiB boundary
|
|
323
|
-
|
|
324
|
-
There is one hard limit worth knowing before anything else: **a full load cannot read a file larger than
|
|
325
|
-
2 GiB**, because Node caps `fs.readFile` there. It fails with a raw Node error rather than a `QvdError`:
|
|
326
|
-
|
|
327
|
-
```
|
|
328
|
-
RangeError: File size (2362232013) is greater than 2 GiB
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
**`maxRows` reads past that boundary**, because the lazy path reads the file in chunks instead:
|
|
332
|
-
|
|
333
|
-
```javascript
|
|
334
|
-
// A 2.2 GiB file
|
|
335
|
-
await QvdDataFrame.fromQvd(huge, {}); // ❌ RangeError, above
|
|
336
|
-
await QvdDataFrame.fromQvd(huge, {maxRows: 1000}); // ✅ works
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
So above 2 GiB, `maxRows` is not an optimisation — it is the only way in. Removing the full-load ceiling is
|
|
340
|
-
tracked in [#122](https://github.com/ptarmiganlabs/qvdjs/issues/122).
|
|
341
|
-
|
|
342
|
-
Below that boundary, what limits you is memory rather than file size, which is the rest of this section.
|
|
343
|
-
|
|
344
|
-
**Simple Explanation:**
|
|
345
|
-
|
|
346
|
-
Node.js has memory limits that affect how large QVD files you can work with. By default, Node.js can use up to about 4GB of memory. This means if you try to load a very large QVD file, you might run out of memory and get an error. Think of it like trying to open a very large document on a computer with limited RAM - if the document is too big, it won't open.
|
|
347
|
-
|
|
348
|
-
**What happens when files are too large:**
|
|
349
|
-
|
|
350
|
-
- **When opening large files**: If a QVD file exceeds available memory, Node.js will throw an out-of-memory error (typically "JavaScript heap out of memory" or "FATAL ERROR: Reached heap limit"). The process will crash before completing the file load.
|
|
351
|
-
- **When saving large files**: Writing very large QVD files can similarly exhaust memory during symbol table and index table construction, causing the same out-of-memory errors before the file is written to disk.
|
|
352
|
-
- **Performance degradation**: Even before running out of memory completely, you may notice significant slowdowns, high memory usage, and system swapping as files approach memory limits.
|
|
353
|
-
|
|
354
|
-
The good news is that Node.js memory limits can be increased (though there will always be some limit), and the actual file size you can handle depends on your data.
|
|
355
|
-
|
|
356
|
-
The most common question at this point is usually:
|
|
357
|
-
|
|
358
|
-
> "How large of a QVD file can I work with using qvdjs?"
|
|
359
|
-
|
|
360
|
-
There is unfortunately no simple answer to this question, as it very much depends on the characteristics of what data is inside the QVD file. See below for more details.
|
|
361
|
-
|
|
362
|
-
**Technical Details:**
|
|
363
|
-
|
|
364
|
-
The maximum QVD file size you can handle with qvdjs depends on several factors and there is no single fixed limit:
|
|
365
|
-
|
|
366
|
-
- **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.
|
|
367
|
-
|
|
368
|
-
You can also adjust the `memorySafetyFactor` option (default 0.8 = 80% of the budget) to trade
|
|
369
|
-
headroom for reach:
|
|
79
|
+
`fromQvd(path, {maxRows: 0})` is **not** a substitute for `readMetadata`: it loads no rows but still
|
|
80
|
+
parses the whole symbol table, which grows with the data.
|
|
370
81
|
|
|
371
|
-
|
|
372
|
-
const df = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
373
|
-
memorySafetyFactor: 0.9, // Use 90% of the budget instead of the default 80%
|
|
374
|
-
});
|
|
375
|
-
```
|
|
82
|
+
→ [Choosing an entry point](https://qvdjs.ptarmiganlabs.com/v1.0/getting-started/)
|
|
376
83
|
|
|
377
|
-
|
|
378
|
-
`process.constrainedMemory()`. Those are the two limits that actually terminate a process — exceeding the heap
|
|
379
|
-
is a fatal, uncatchable V8 error, and exceeding a cgroup limit is a SIGKILL that arrives as exit 137 with no
|
|
380
|
-
JavaScript error at all.
|
|
84
|
+
## What a cell holds
|
|
381
85
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
86
|
+
| The file stores | The cell holds |
|
|
87
|
+
| ------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
88
|
+
| A number | A `number` |
|
|
89
|
+
| A string | A `string`, never parsed - `'007'` stays `'007'` |
|
|
90
|
+
| A dual: a number with the text Qlik displays, such as a date's text | Its `number`. The text is kept, and `textAt` returns it |
|
|
91
|
+
| NULL | `null` |
|
|
388
92
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
```javascript
|
|
394
|
-
const df = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
To phrase it differently: There is no magic here - viewing a 40 GB QVD on a laptop with 24 GB RAM will not work. That laptop may in fact struggle with QVD files larger than 4-6 GB depending on data characteristics - or happily work with 10+ GB files if the data is very friendly.
|
|
398
|
-
|
|
399
|
-
- **Data Characteristics**: The actual memory consumption depends _heavily_ on what's inside your QVD:
|
|
400
|
-
- **Field Cardinality**: Files with high-cardinality fields (many unique values per field, like unique IDs or timestamps) require more memory for symbol tables. This is usually the biggest factor, at least if there are many rows too.
|
|
401
|
-
- **Number of Rows**: More rows mean larger index tables in memory
|
|
402
|
-
- **Number of Fields**: More columns increase overall memory requirements
|
|
403
|
-
- **Data Types**: String data generally uses more memory than numeric data
|
|
404
|
-
|
|
405
|
-
- **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.
|
|
406
|
-
|
|
407
|
-
- **Practical Guidance**:
|
|
408
|
-
- **Count cells, not megabytes.** What exhausts the heap on a full load is one array per row plus
|
|
409
|
-
one slot per cell, so a file's size on disk predicts very little. On a default 4 GB heap the
|
|
410
|
-
guard currently accepts roughly:
|
|
411
|
-
|
|
412
|
-
| Columns | Rows | Cells |
|
|
413
|
-
| ------- | ------ | ----- |
|
|
414
|
-
| 5 | 30.5 M | 153 M |
|
|
415
|
-
| 10 | 22.5 M | 225 M |
|
|
416
|
-
| 20 | 14.7 M | 295 M |
|
|
417
|
-
| 40 | 8.7 M | 349 M |
|
|
418
|
-
|
|
419
|
-
These are about 4× what they were before 2026-09-11, for two reasons: the bitwise decode
|
|
420
|
-
(#134) cut what a read actually needs by roughly 2.4×, and the guard's model was recalibrated
|
|
421
|
-
against that — it had been over-estimating a real read by 4.8×. The guard still errs high,
|
|
422
|
-
admitting a load only at 1.3–2× the heap it measurably needs, because under-estimating means
|
|
423
|
-
an uncatchable abort while over-estimating means a catchable refusal. Doubling the heap with
|
|
424
|
-
`--max-old-space-size` roughly doubles these numbers.
|
|
425
|
-
|
|
426
|
-
- **A columnar read is not bounded by any of this.** `QvdColumnTable.fromQvd()` stores codes in
|
|
427
|
-
typed arrays, which live outside the V8 heap, so the heap ceiling barely applies: the 38 MB
|
|
428
|
-
taxi fixture reads columnar in a 15 MB heap, where the same file as rows needs 367 MB. If you
|
|
429
|
-
are hitting the table above, reading the columns you need is usually the answer rather than a
|
|
430
|
-
bigger heap.
|
|
431
|
-
|
|
432
|
-
- High-cardinality data (unique values in most rows) also loads the symbol table, so the ceiling
|
|
433
|
-
drops further — subtract about 90 MB of heap per million distinct values
|
|
434
|
-
- **With increased heap** (e.g., 16GB+), you can handle proportionally larger files by adjusting `memorySafetyFactor`
|
|
435
|
-
- Use lazy loading (`maxRows` option) when possible to reduce memory footprint when reading
|
|
436
|
-
- Monitor memory usage with tools like `process.memoryUsage()` for your specific use cases
|
|
437
|
-
- Consider processing large datasets in chunks or using streaming approaches if you hit memory limits. Clever things can be done by doing multiple passes over the file instead of loading everything at once.
|
|
438
|
-
|
|
439
|
-
If you consistently work with very large QVD files, consider increasing Node.js memory limits (with matching `memorySafetyFactor` adjustment) or splitting your data into multiple smaller QVD files.
|
|
440
|
-
|
|
441
|
-
#### Why Safety Limits Exist
|
|
442
|
-
|
|
443
|
-
The library implements **dynamic safety limits** to prevent catastrophic crashes. Without these limits:
|
|
444
|
-
|
|
445
|
-
- **Your application will crash hard** - Node.js terminates with `FATAL ERROR: Reached heap limit` when attempting to load files that are too large
|
|
446
|
-
- **No error handling is possible** - JavaScript try-catch blocks cannot intercept out-of-memory (OOM) crashes at the V8 engine level
|
|
447
|
-
- **The entire process dies** - Not just the QVD operation, but your entire application terminates ungracefully
|
|
448
|
-
|
|
449
|
-
The safety limits **prevent these crashes** by checking available memory _before_ attempting to load files, throwing graceful `QvdValidationError` exceptions that you can catch and handle. While the multi-tier safety system may seem complex, it ensures your application stays running and provides helpful error messages with recommendations (like using `maxRows` parameter or increasing heap size) instead of cryptic fatal errors.
|
|
450
|
-
|
|
451
|
-
**Example without safety limits:**
|
|
93
|
+
`{duals: 'text'}` reads a dual as its text instead, and `{duals: 'both'}` as a frozen `QvdDual` holding
|
|
94
|
+
both halves. `{coerceNumericStrings: true}` reads a numeric-looking string as a number. Whichever way a
|
|
95
|
+
frame was read, it writes back the symbols it was read from.
|
|
452
96
|
|
|
453
97
|
```javascript
|
|
454
|
-
|
|
455
|
-
const df = await QvdDataFrame.fromQvd('huge-file.qvd'); // 💥 FATAL ERROR
|
|
456
|
-
// Your application is now terminated
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
**Example with safety limits:**
|
|
98
|
+
import {QvdDataFrame, qlikSerialToDate} from 'qvdjs';
|
|
460
99
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (error.name === 'QvdValidationError') {
|
|
466
|
-
console.log('File too large, trying with maxRows:', error.context.recommendedMaxRows);
|
|
467
|
-
// ✅ Your application continues running
|
|
468
|
-
}
|
|
469
|
-
}
|
|
100
|
+
const df = await QvdDataFrame.fromQvd('rides.qvd');
|
|
101
|
+
df.data[0][0]; // 42382.260416666664 - the date is its number, as Qlik sums and sorts it
|
|
102
|
+
df.textAt(0, 'started'); // '2016-01-13 06:15:00' - the text Qlik displays
|
|
103
|
+
qlikSerialToDate(df.data[0][0]).toISOString(); // '2016-01-13T06:15:00.000Z'
|
|
470
104
|
```
|
|
471
105
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
### Writing QVD files
|
|
475
|
-
|
|
476
|
-
`toQvd()` builds the symbol and index tables in memory and writes the file in one pass. Two things are worth
|
|
477
|
-
knowing about it.
|
|
478
|
-
|
|
479
|
-
**Row count is no longer a barrier.** Writing used to fail outright above roughly 122,000 rows with a
|
|
480
|
-
`RangeError: Maximum call stack size exceeded`, because the bit width for each column was derived with
|
|
481
|
-
`Math.max(...oneArgumentPerRow)`. It is now derived from the symbol count in constant time, and writes of
|
|
482
|
-
hundreds of thousands of rows are routine. The regression suite round-trips a 200,000-row data frame and a
|
|
483
|
-
150,000-row slice of real Qlik output on every CI run, across Linux, Windows and macOS.
|
|
484
|
-
|
|
485
|
-
**Memory scales with the data, not with the file you are writing.** A write holds the whole data frame plus its
|
|
486
|
-
symbol and index tables, so it needs more memory than reading the equivalent file. There is no lazy equivalent
|
|
487
|
-
of `maxRows` for writing — if the data does not fit in the heap, split it across several QVDs.
|
|
488
|
-
|
|
489
|
-
Writes are not atomic; see [Known limitations](#known-limitations).
|
|
490
|
-
|
|
491
|
-
#### Progress tracking
|
|
492
|
-
|
|
493
|
-
When writing large QVD files, the `toQvd()` operation can take significant time. The library provides optional progress callbacks to track the write operation in real-time:
|
|
106
|
+
## Writing
|
|
494
107
|
|
|
495
108
|
```javascript
|
|
496
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
497
|
-
|
|
498
109
|
const df = await QvdDataFrame.fromDict({
|
|
499
|
-
columns: ['
|
|
500
|
-
data:
|
|
110
|
+
columns: ['Region', 'Year', 'Amount'],
|
|
111
|
+
data: [
|
|
112
|
+
['North', 2025, 1200.0],
|
|
113
|
+
['South', 2025, 980.5],
|
|
114
|
+
],
|
|
501
115
|
});
|
|
502
116
|
|
|
503
|
-
await df.toQvd('
|
|
504
|
-
onProgress: (
|
|
505
|
-
console.log(`${progress.stage}: ${progress.percent}% complete`);
|
|
506
|
-
},
|
|
117
|
+
await df.toQvd('out.qvd', {
|
|
118
|
+
onProgress: ({stage, percent}) => console.log(`${stage}: ${percent}%`),
|
|
507
119
|
});
|
|
508
120
|
```
|
|
509
121
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
- `current` (number): Current progress value (e.g., rows processed, columns completed)
|
|
516
|
-
- `total` (number): Total progress value for the current stage
|
|
517
|
-
- `percent` (number): Progress percentage (0-100) calculated as `(current / total) * 100`
|
|
518
|
-
|
|
519
|
-
**Progress stages:**
|
|
520
|
-
|
|
521
|
-
- `symbol-table`: Building unique value tables for each column
|
|
522
|
-
- `index-table`: Processing all data rows and creating index mappings
|
|
523
|
-
- `header`: Generating XML header metadata
|
|
524
|
-
- `write`: Writing data to disk
|
|
525
|
-
|
|
526
|
-
**Performance optimizations:**
|
|
527
|
-
|
|
528
|
-
The library uses optimized algorithms for large dataset processing:
|
|
529
|
-
|
|
530
|
-
- **Single-pass symbol table building**: All columns are processed in one pass through the data (previously required one pass per column)
|
|
531
|
-
- **Map-based lookups**: O(1) symbol index lookups instead of O(n) findIndex operations
|
|
532
|
-
- **Reduced algorithmic complexity**: From O(n×m×s) to O(n×m) where n=rows, m=columns, s=symbols
|
|
122
|
+
Files it produces open in Qlik Sense and QlikView. A number is written as a pure number - an integer
|
|
123
|
+
within 32 bits as an integer, any other number as a double - a string as a string, and `null` as
|
|
124
|
+
NULL. A dual value - a `QvdDual`, or an object with exactly the keys `number` and `text` - is written
|
|
125
|
+
as a dual, which is how a number gets the text Qlik displays. As in Qlik, a field holds one text per
|
|
126
|
+
number, the first written. Anything else is refused with a `QvdValidationError`.
|
|
533
127
|
|
|
534
|
-
|
|
128
|
+
→ [Writing a QVD](https://qvdjs.ptarmiganlabs.com/v1.0/guides/)
|
|
535
129
|
|
|
536
|
-
|
|
130
|
+
## Reading part of a file
|
|
537
131
|
|
|
538
|
-
|
|
539
|
-
|
|
132
|
+
The three calls that read data share the same options — `offset`, `limit` (or `maxRows`), `fields`,
|
|
133
|
+
`duals`, `coerceNumericStrings`, `onProgress` and `signal` — because they are three answers about the
|
|
134
|
+
same file rather than three features.
|
|
540
135
|
|
|
541
136
|
```javascript
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
Note that `fromQvd(path, {maxRows: 0})` is not equivalent — it loads no rows, but still reads and
|
|
550
|
-
parses the whole symbol table.
|
|
551
|
-
|
|
552
|
-
For metadata alongside the data, every accessor below is available on a loaded frame:
|
|
553
|
-
|
|
554
|
-
```javascript
|
|
555
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
556
|
-
|
|
557
|
-
// Load QVD file
|
|
558
|
-
const df = await QvdDataFrame.fromQvd('path/to/file.qvd');
|
|
559
|
-
|
|
560
|
-
// Access file-level metadata
|
|
561
|
-
console.log(df.fileMetadata.tableName);
|
|
562
|
-
console.log(df.fileMetadata.createUtcTime);
|
|
563
|
-
console.log(df.fileMetadata.noOfRecords);
|
|
564
|
-
|
|
565
|
-
// Access field-level metadata
|
|
566
|
-
const fieldMeta = df.getFieldMetadata('ProductKey');
|
|
567
|
-
console.log(fieldMeta.comment);
|
|
568
|
-
console.log(fieldMeta.numberFormat);
|
|
569
|
-
console.log(fieldMeta.tags);
|
|
570
|
-
|
|
571
|
-
// Get all field metadata
|
|
572
|
-
const allFields = df.getAllFieldMetadata();
|
|
573
|
-
allFields.forEach((field) => {
|
|
574
|
-
console.log(`${field.fieldName}: ${field.noOfSymbols} symbols`);
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
// Modify metadata (only modifiable properties can be changed)
|
|
578
|
-
df.setFileMetadata({
|
|
579
|
-
tableName: 'UpdatedProducts',
|
|
580
|
-
comment: 'Modified product data',
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
df.setFieldMetadata('ProductKey', {
|
|
584
|
-
comment: 'Primary key for products',
|
|
585
|
-
tags: {String: ['$key', '$numeric']},
|
|
137
|
+
// A window, and only the columns you need.
|
|
138
|
+
const df = await QvdDataFrame.fromQvd('large.qvd', {
|
|
139
|
+
offset: 1_000_000,
|
|
140
|
+
limit: 1000,
|
|
141
|
+
fields: ['Region', 'Amount'],
|
|
586
142
|
});
|
|
587
143
|
|
|
588
|
-
|
|
589
|
-
|
|
144
|
+
console.log(df.loadStats);
|
|
145
|
+
// { symbolTableBytes, totalRows, rowsLoaded, offset, symbolFiltering, symbolsKept }
|
|
590
146
|
```
|
|
591
147
|
|
|
592
|
-
|
|
148
|
+
**Above 2 GiB a full load cannot work at all** — Node caps `fs.readFile` there and it fails with a
|
|
149
|
+
raw `RangeError`. A windowed read is not an optimisation above that boundary, it is the only way in.
|
|
593
150
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
By default, all file operations are restricted to the **current working directory (CWD)** and its subdirectories. This means:
|
|
599
|
-
|
|
600
|
-
- ✅ Files within CWD can be accessed: `./data/file.qvd` or `data/file.qvd`
|
|
601
|
-
- ❌ Files outside CWD are blocked: `/etc/passwd` or `../../../sensitive.qvd`
|
|
602
|
-
- ❌ Path traversal attempts are detected and blocked
|
|
603
|
-
|
|
604
|
-
This default behavior protects against path traversal attacks without requiring additional configuration.
|
|
605
|
-
|
|
606
|
-
**Custom Directory Restriction:**
|
|
607
|
-
|
|
608
|
-
You can explicitly specify a different base directory using the `allowedDir` option:
|
|
151
|
+
Below it the limit is memory rather than file size, and a read too large to fit throws a **catchable**
|
|
152
|
+
`QvdValidationError` carrying a row count that would have fitted, instead of a fatal
|
|
153
|
+
`Reached heap limit` that no `try`/`catch` can intercept:
|
|
609
154
|
|
|
610
155
|
```javascript
|
|
611
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
612
|
-
|
|
613
|
-
// Restrict reading to a specific directory
|
|
614
|
-
const allowedDataDir = '/var/data/qvd-files';
|
|
615
|
-
const df = await QvdDataFrame.fromQvd('reports/sales.qvd', {
|
|
616
|
-
allowedDir: allowedDataDir,
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// Restrict writing to a specific directory
|
|
620
|
-
const allowedOutputDir = '/var/output';
|
|
621
|
-
await df.toQvd('processed/sales-filtered.qvd', {
|
|
622
|
-
allowedDir: allowedOutputDir,
|
|
623
|
-
});
|
|
624
|
-
```
|
|
625
|
-
|
|
626
|
-
**Security Features:**
|
|
627
|
-
|
|
628
|
-
- **Path Normalization**: All paths are automatically normalized using `path.resolve()` to eliminate `..` and `.` segments
|
|
629
|
-
- **Null Byte Protection**: Detects and blocks null byte injection attempts
|
|
630
|
-
- **Default CWD Restriction**: By default, file operations are restricted to the current working directory (CWD) and its subdirectories to prevent path traversal attacks
|
|
631
|
-
- **Custom Directory Restriction**: Optional `allowedDir` parameter allows you to specify a different base directory
|
|
632
|
-
- **Symlinks are resolved**: Containment is decided by the filesystem, not by comparing strings. Both paths are
|
|
633
|
-
resolved through symlinks and compared by device and inode, so a link _inside_ `allowedDir` that points
|
|
634
|
-
outside it is refused — for writes as well as reads. A string comparison sees only the link's own name, still
|
|
635
|
-
under `allowedDir`, and lets it through; that meant a link planted in an upload directory could be used to
|
|
636
|
-
read any file the process could read, and to overwrite and truncate any file it could write.
|
|
637
|
-
- **Case is handled as the filesystem handles it**: comparing by inode means `Qvd` and `qvd` are the same
|
|
638
|
-
directory on a case-insensitive volume and different directories on a case-sensitive one, without guessing
|
|
639
|
-
from `process.platform`. macOS supports both.
|
|
640
|
-
- **Security Errors**: Throws `QvdSecurityError` with detailed context when security violations are detected.
|
|
641
|
-
The context includes a `check` field saying whether the filesystem or the fallback string comparison refused,
|
|
642
|
-
so a rejection of a path that looks contained is traceable to a symlink or a case difference.
|
|
643
|
-
|
|
644
|
-
**A note on `allowedDir` values:** `null`, `undefined` and `''` all fall back to the current working directory
|
|
645
|
-
rather than meaning "no restriction" — callers routinely produce those from optional config or a JSON round
|
|
646
|
-
trip, and silently dropping the sandbox there would be a security hole. To permit an entire volume, pass its
|
|
647
|
-
root explicitly (`'/'` on POSIX, `'C:\\'` on Windows).
|
|
648
|
-
|
|
649
|
-
**Not covered:** this is a check on a path, so it remains open to a symlink swapped in between the check and the
|
|
650
|
-
open. Closing that would require opening with `O_NOFOLLOW` and verifying the descriptor.
|
|
651
|
-
|
|
652
|
-
**Best Practices:**
|
|
653
|
-
|
|
654
|
-
1. **Understand default security**: Files are restricted to CWD by default - no additional configuration needed for basic protection
|
|
655
|
-
2. **Use explicit `allowedDir` in production**: When your application's CWD differs from your data directory, specify an explicit `allowedDir` for user-provided file paths
|
|
656
|
-
3. **Validate user input**: Even with built-in protections, validate and sanitize any user-provided paths
|
|
657
|
-
4. **Principle of least privilege**: Use the most restrictive `allowedDir` possible for your use case
|
|
658
|
-
5. **Monitor security errors**: Log and monitor `QvdSecurityError` exceptions as they may indicate attack attempts
|
|
659
|
-
|
|
660
|
-
**Examples:**
|
|
661
|
-
|
|
662
|
-
```javascript
|
|
663
|
-
import {QvdDataFrame, QvdSecurityError} from 'qvdjs';
|
|
664
|
-
|
|
665
|
-
// Example 1: Default behavior (restricted to CWD)
|
|
666
|
-
// This is safe by default - no path traversal possible
|
|
667
156
|
try {
|
|
668
|
-
const df = await QvdDataFrame.fromQvd('
|
|
669
|
-
const df2 = await QvdDataFrame.fromQvd('../../../etc/passwd'); // ❌ Throws QvdSecurityError
|
|
157
|
+
const df = await QvdDataFrame.fromQvd('huge.qvd');
|
|
670
158
|
} catch (error) {
|
|
671
|
-
if (error
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// Example 2: Custom allowedDir for specific use cases
|
|
677
|
-
try {
|
|
678
|
-
const df = await QvdDataFrame.fromQvd(userProvidedPath, {
|
|
679
|
-
allowedDir: '/safe/directory',
|
|
680
|
-
});
|
|
681
|
-
// Process data...
|
|
682
|
-
} catch (error) {
|
|
683
|
-
if (error instanceof QvdSecurityError) {
|
|
684
|
-
console.error('Security violation detected:', error.message);
|
|
685
|
-
console.error('Context:', error.context);
|
|
686
|
-
// Log security incident
|
|
687
|
-
} else {
|
|
688
|
-
throw error;
|
|
159
|
+
if (error.name === 'QvdValidationError') {
|
|
160
|
+
await QvdDataFrame.fromQvd('huge.qvd', {maxRows: error.context.recommendedMaxRows});
|
|
689
161
|
}
|
|
690
162
|
}
|
|
691
163
|
```
|
|
692
164
|
|
|
693
|
-
|
|
165
|
+
When it is `iterate()` that overflowed, the context carries `recommendedChunkSize` as well, so a
|
|
166
|
+
caller cannot mistake one recommendation for the other.
|
|
694
167
|
|
|
695
|
-
|
|
168
|
+
→ [Memory and file size limits](https://qvdjs.ptarmiganlabs.com/v1.0/overview/memory-and-file-size-limits/)
|
|
696
169
|
|
|
697
|
-
|
|
698
|
-
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
699
|
-
| **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) |
|
|
700
|
-
| **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. `{offset, limit}` reads an arbitrary window, `iterate()` walks a file in bounded chunks, and `QvdColumnTable` avoids row materialisation entirely — but **the symbol table is still parsed in full on every one of those paths**, so none of them is constant-memory in the size of a high-cardinality file. | [#140](https://github.com/ptarmiganlabs/qvdjs/issues/140) |
|
|
701
|
-
| **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) |
|
|
702
|
-
| **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) |
|
|
703
|
-
| **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) |
|
|
704
|
-
|
|
705
|
-
Numeric-looking strings are currently coerced to numbers on read, which is being reconsidered as a breaking
|
|
706
|
-
change in [#120](https://github.com/ptarmiganlabs/qvdjs/issues/120). Empty and whitespace-only strings are _not_
|
|
707
|
-
coerced — that was a defect, and is fixed.
|
|
708
|
-
|
|
709
|
-
## QVD File Format
|
|
710
|
-
|
|
711
|
-
The QVD file format is a binary file format that is used by QlikView to store data. The format is proprietary. However,
|
|
712
|
-
the format is well documented and can be parsed without the need of a QlikView installation. In fact, a QVD file consists
|
|
713
|
-
of three parts: a XML header, and two binary parts, the symbol and the index table. The XML header contains meta information
|
|
714
|
-
about the QVD file, such as the number of data records and the names of the fields. The symbol table contains the actual
|
|
715
|
-
distinct values of the fields. The index table contains the actual data records. The index table is a list of indices
|
|
716
|
-
which point to values in the symbol table.
|
|
717
|
-
|
|
718
|
-
### XML Header
|
|
719
|
-
|
|
720
|
-
The XML header contains meta information about the QVD file. The header is always located at the beginning of the file and
|
|
721
|
-
is in human readable text format. The header contains information about the number of data records, the names of the fields,
|
|
722
|
-
and the data types of the fields.
|
|
723
|
-
|
|
724
|
-
### Symbol Table
|
|
725
|
-
|
|
726
|
-
The symbol table contains the distinct/unique values of the fields and is located directly after the XML header. The order
|
|
727
|
-
of columns in the symbol table corresponds to the order of the fields in the XML header. The length and offset of the
|
|
728
|
-
symbol sections of each column are also stored in the XML header.
|
|
729
|
-
|
|
730
|
-
**Important**: The offset values in the XML header are **relative to the start of the symbol table section**, not absolute file positions. For example, if a field has `Offset=1143`, this means its symbol data starts 1143 bytes after the symbol table section begins (which itself starts immediately after the XML header ends).
|
|
731
|
-
|
|
732
|
-
Each symbol section consists of the unique symbols of the
|
|
733
|
-
respective column. The type of a single symbol is determined by a type byte prefixed to the respective symbol value. The
|
|
734
|
-
following type of symbols are supported:
|
|
735
|
-
|
|
736
|
-
| Code | Type | Description |
|
|
737
|
-
| ---- | ------------ | --------------------------------------------------------------------------------------------- |
|
|
738
|
-
| 1 | Integer | signed 4-byte integer (little endian) |
|
|
739
|
-
| 2 | Float | signed 8-byte IEEE floating point number (little endian) |
|
|
740
|
-
| 4 | String | null terminated string |
|
|
741
|
-
| 5 | Dual Integer | signed 4-byte integer (little endian) followed by a null terminated string |
|
|
742
|
-
| 6 | Dual Float | signed 8-byte IEEE floating point number (little endian) followed by a null terminated string |
|
|
743
|
-
|
|
744
|
-
### Index Table
|
|
745
|
-
|
|
746
|
-
After the symbol table, the index table follows. The index table contains the actual data records. The index table contains
|
|
747
|
-
binary indices that refrences to the values of each row in the symbol table. The order of the columns in the index table
|
|
748
|
-
corresponds to the order of the fields in the XML header. Hence, the index table does not contain the actual values of a
|
|
749
|
-
data record, but only the indices that point to the values in the symbol table.
|
|
750
|
-
|
|
751
|
-
### Empty QVD Files
|
|
752
|
-
|
|
753
|
-
QVD files can be empty in the sense that they contain zero data rows while still maintaining valid field definitions and metadata. This is a valid use case in Qlik applications where table structures need to be preserved even when no data is available.
|
|
754
|
-
|
|
755
|
-
**Characteristics of Empty QVD Files:**
|
|
756
|
-
|
|
757
|
-
- **NoOfRecords**: Set to `0` in the XML header
|
|
758
|
-
- **RecordByteSize**: Set to `1` (following Qlik Sense's convention)
|
|
759
|
-
- **Fields**: Field definitions are present and complete with metadata
|
|
760
|
-
- **Symbol Table**: Each field has zero symbols (`NoOfSymbols=0`, `Offset=0`, `Length=0`)
|
|
761
|
-
- **Index Table**: Empty with `Length=0`
|
|
762
|
-
- **BitWidth**: Can vary per field (typically `0` or `8`)
|
|
763
|
-
|
|
764
|
-
**Example Empty QVD XML Header:**
|
|
765
|
-
|
|
766
|
-
```xml
|
|
767
|
-
<QvdTableHeader>
|
|
768
|
-
<TableName>EmptyTable</TableName>
|
|
769
|
-
<Fields>
|
|
770
|
-
<QvdFieldHeader>
|
|
771
|
-
<FieldName>Country</FieldName>
|
|
772
|
-
<BitOffset>0</BitOffset>
|
|
773
|
-
<BitWidth>0</BitWidth>
|
|
774
|
-
<Bias>0</Bias>
|
|
775
|
-
<NoOfSymbols>0</NoOfSymbols>
|
|
776
|
-
<Offset>0</Offset>
|
|
777
|
-
<Length>0</Length>
|
|
778
|
-
</QvdFieldHeader>
|
|
779
|
-
</Fields>
|
|
780
|
-
<RecordByteSize>1</RecordByteSize>
|
|
781
|
-
<NoOfRecords>0</NoOfRecords>
|
|
782
|
-
<Offset>0</Offset>
|
|
783
|
-
<Length>0</Length>
|
|
784
|
-
</QvdTableHeader>
|
|
785
|
-
```
|
|
170
|
+
## Metadata
|
|
786
171
|
|
|
787
|
-
|
|
172
|
+
Table name, comments, number formats and field tags survive a read, can be changed on any frame, and
|
|
173
|
+
are written back out - less a tag or number format the written values contradict, such as `$numeric`
|
|
174
|
+
on a field that now holds text.
|
|
788
175
|
|
|
789
176
|
```javascript
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
//
|
|
793
|
-
const emptyDf = new QvdDataFrame(
|
|
794
|
-
[], // No data rows
|
|
795
|
-
['Country', 'Year', 'Sales'], // Column definitions
|
|
796
|
-
);
|
|
797
|
-
|
|
798
|
-
// Set metadata
|
|
799
|
-
emptyDf.setFileMetadata({
|
|
800
|
-
tableName: 'EmptyTable',
|
|
801
|
-
comment: 'Template table structure',
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
// Write empty QVD (compatible with Qlik Sense)
|
|
805
|
-
await emptyDf.toQvd('empty.qvd');
|
|
177
|
+
const df = await QvdDataFrame.fromQvd('products.qvd');
|
|
178
|
+
df.fileMetadata.tableName; // 'Products'
|
|
179
|
+
df.getFieldMetadata('ProductKey'); // { comment, numberFormat, tags, ... }
|
|
806
180
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
console.log(loadedDf.shape); // [0, 3] - zero rows, three columns
|
|
810
|
-
console.log(loadedDf.columns); // ['Country', 'Year', 'Sales']
|
|
181
|
+
df.setFileMetadata({tableName: 'UpdatedProducts'});
|
|
182
|
+
await df.toQvd('products-v2.qvd');
|
|
811
183
|
```
|
|
812
184
|
|
|
813
|
-
|
|
185
|
+
→ [Metadata reference](https://qvdjs.ptarmiganlabs.com/v1.0/reference/)
|
|
814
186
|
|
|
815
|
-
##
|
|
187
|
+
## File paths are sandboxed
|
|
816
188
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
The `QvdDataFrame` class represents the data frame stored inside of a finally parsed QVD file. It provides a high-level
|
|
820
|
-
abstraction access to the QVD file content. This includes meta information as well as access to the actual data records.
|
|
821
|
-
|
|
822
|
-
| Property | Type | Description |
|
|
823
|
-
| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
824
|
-
| `shape` | `number[]` | The shape of the data table. The first element is the number of rows, the second element is the number of columns. |
|
|
825
|
-
| `data` | `any[][]` | The actual data records of the QVD file. The first dimension represents the single rows. |
|
|
826
|
-
| `columns` | `string[]` | The names of the fields that are contained in the QVD file. |
|
|
827
|
-
| `metadata` | `object` | The complete metadata object from the QVD file header, or null if not loaded from a QVD file. |
|
|
828
|
-
| `fileMetadata` | `object` | File-level metadata from the QVD header (qvBuildNo, tableName, createUtcTime, etc.). |
|
|
829
|
-
| `loadStats` | `object` | What the read did: `symbolTableBytes`, `totalRows`, `rowsLoaded`, `offset`, `symbolFiltering`, `symbolsKept`. Carried by `fromQvd()`, by every chunk `iterate()` yields, and by `QvdColumnTable`; `null` on frames from `fromDict()`, `head()`, `tail()`, `rows()` and `select()`, which describe no particular read. |
|
|
830
|
-
|
|
831
|
-
#### `static fromQvd(path: string, options?: object): Promise<QvdDataFrame>`
|
|
832
|
-
|
|
833
|
-
The static method `QvdDataFrame.fromQvd` loads a QVD file from the given path and parses it. The method returns a promise that resolves
|
|
834
|
-
to a `QvdDataFrame` instance.
|
|
835
|
-
|
|
836
|
-
**Parameters:**
|
|
837
|
-
|
|
838
|
-
- `path` (string): The path to the QVD file.
|
|
839
|
-
- `options` (object, optional): Loading options
|
|
840
|
-
- `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. **`maxRows` and `limit` are the same option under two names** — see [Reading part of a file](#reading-part-of-a-file). Passing both throws.
|
|
841
|
-
- `limit` (number, optional): Rows to read, counting from `offset`. The same number as `maxRows`, spelled so that it reads correctly beside an offset.
|
|
842
|
-
- `offset` (number, optional): File row to start at. Defaults to 0. An offset past the end of the file returns **no rows** rather than throwing, so a paging loop terminates on its own.
|
|
843
|
-
- `fields` (string[], optional): Field names to read, in the order they should appear in the result. Unselected fields have their symbols **skipped entirely** rather than parsed and discarded. An unknown or repeated name throws.
|
|
844
|
-
- `onProgress` (function, optional): Called with `{stage, current, total, percent}` as the read proceeds — the same shape `toQvd`'s callback receives. See [Read progress and cancellation](#read-progress-and-cancellation).
|
|
845
|
-
- `signal` (AbortSignal, optional): Cancels the read. The rejection is `signal.reason`.
|
|
846
|
-
- `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.
|
|
847
|
-
- `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). Lower it (e.g. to 0.5) to leave the garbage collector more room; raise it toward 1.0 to use more of the budget. The figures either side of 0.8 are decreases and increases respectively — an earlier default of 0.3 is why this line used to call 0.5 an increase. **`0` disables the memory check entirely.**
|
|
848
|
-
- `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.
|
|
849
|
-
|
|
850
|
-
**Example:**
|
|
189
|
+
Every path is checked against an allowed directory before it is opened. **Leaving the option out
|
|
190
|
+
does not mean "anywhere"** — it means the current working directory.
|
|
851
191
|
|
|
852
192
|
```javascript
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
// Load only the first 1000 rows
|
|
857
|
-
const dfLazy = await QvdDataFrame.fromQvd('path/to/file.qvd', {maxRows: 1000});
|
|
858
|
-
|
|
859
|
-
// Load with security restriction (recommended for production)
|
|
860
|
-
const dfSecure = await QvdDataFrame.fromQvd('reports/sales.qvd', {
|
|
861
|
-
allowedDir: '/var/data/qvd-files',
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
// Load with increased memory usage for large heap configurations
|
|
865
|
-
const dfLarge = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
866
|
-
memorySafetyFactor: 0.9, // Use 90% of the budget instead of the default 80%
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
// Manage memory yourself: skip the check entirely
|
|
870
|
-
const dfUnchecked = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
|
|
871
|
-
|
|
872
|
-
// Inspect what the read actually did
|
|
873
|
-
const preview = await QvdDataFrame.fromQvd('large-file.qvd', {maxRows: 500});
|
|
874
|
-
console.log(preview.loadStats.symbolFiltering); // true when the two-pass path ran
|
|
875
|
-
console.log(preview.loadStats.symbolsKept); // how many symbols it kept
|
|
876
|
-
|
|
877
|
-
// An arbitrary row window, and only the columns you need
|
|
878
|
-
const page = await QvdDataFrame.fromQvd('trips.qvd', {offset: 1_000_000, limit: 500});
|
|
879
|
-
const narrow = await QvdDataFrame.fromQvd('trips.qvd', {fields: ['fare', 'trip_miles']});
|
|
193
|
+
await QvdDataFrame.fromQvd('sales.qvd', {allowedDir: '/var/data/qvd'});
|
|
194
|
+
await QvdDataFrame.fromQvd(anyPath, {allowedDir: '/'}); // deliberately unrestricted
|
|
880
195
|
```
|
|
881
196
|
|
|
882
|
-
|
|
197
|
+
Containment is decided by the filesystem — both paths are resolved through symlinks and compared by
|
|
198
|
+
device and inode — so a link inside the allowed directory pointing out of it is refused.
|
|
883
199
|
|
|
884
|
-
|
|
885
|
-
a projection is a valid read.
|
|
200
|
+
→ [Path security](https://qvdjs.ptarmiganlabs.com/v1.0/overview/path-security/)
|
|
886
201
|
|
|
887
|
-
|
|
888
|
-
| --------- | ------------------------------------------------------------ |
|
|
889
|
-
| `offset` | File row to start at. Default 0. |
|
|
890
|
-
| `limit` | Rows to read from `offset`. Default: to the end of the file. |
|
|
891
|
-
| `maxRows` | The older name for `limit`. Identical; passing both throws. |
|
|
892
|
-
| `fields` | Field names to read, in the order they should appear. |
|
|
202
|
+
## Errors
|
|
893
203
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
`
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
`{offset: 0, limit: 5}` are one read, resolved in one place so they cannot drift.
|
|
900
|
-
|
|
901
|
-
Passing both throws, even when they agree. Any rule for picking a winner is a rule you would have
|
|
902
|
-
to remember, and getting it wrong returns a plausible number of rows rather than an error.
|
|
903
|
-
|
|
904
|
-
```javascript
|
|
905
|
-
await QvdDataFrame.fromQvd('trips.qvd', {maxRows: 100}); // first 100 rows
|
|
906
|
-
await QvdDataFrame.fromQvd('trips.qvd', {limit: 100}); // the same read
|
|
907
|
-
await QvdDataFrame.fromQvd('trips.qvd', {offset: 500, limit: 100}); // rows 500-599
|
|
908
|
-
await QvdDataFrame.fromQvd('trips.qvd', {offset: 500}); // row 500 to the end
|
|
909
|
-
await QvdDataFrame.fromQvd('trips.qvd', {maxRows: 5, limit: 5}); // throws
|
|
910
|
-
```
|
|
204
|
+
`QvdError` and five subclasses, each carrying a `code` and a `context` object:
|
|
205
|
+
`QvdValidationError` (`QVD_VALIDATION_ERROR`), `QvdCorruptedError` (`QVD_CORRUPTED_ERROR`),
|
|
206
|
+
`QvdParseError` (`QVD_PARSE_ERROR`), `QvdIOError` (`QVD_IO_ERROR`) and `QvdSecurityError`
|
|
207
|
+
(`QVD_SECURITY_ERROR`). Nothing currently throws `QvdIOError`; a filesystem failure surfaces as the
|
|
208
|
+
underlying Node error.
|
|
911
209
|
|
|
912
|
-
|
|
913
|
-
|
|
210
|
+
`error.name` is always the class name, in the CommonJS and ES module builds alike, so it works across
|
|
211
|
+
a module boundary where `instanceof` may not.
|
|
914
212
|
|
|
915
|
-
|
|
916
|
-
the last thousand rows take **31 ms and read 0.46 MiB**, against 663 ms and 37.9 MiB for the only
|
|
917
|
-
thing that was possible before — reading it all and slicing.
|
|
213
|
+
→ [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v1.0/troubleshooting/)
|
|
918
214
|
|
|
919
|
-
|
|
215
|
+
## Known limitations
|
|
920
216
|
|
|
921
|
-
|
|
922
|
-
saving is: the index table is row-major, so every record carries every field's bits and no
|
|
923
|
-
projection can narrow the bytes read from disk. What it avoids is parsing symbols and decoding
|
|
924
|
-
columns.
|
|
925
|
-
|
|
926
|
-
- Taxi fixture, 2 of 20 columns: **162 ms against 674 ms**, 4.2×.
|
|
927
|
-
- A file whose symbol table is 94 % of it, 2 of 8 columns: **96 ms against 290 ms**, 3.0×.
|
|
928
|
-
|
|
929
|
-
```javascript
|
|
930
|
-
const df = await QvdDataFrame.fromQvd('trips.qvd', {fields: ['fare', 'taxi_id']});
|
|
931
|
-
console.log(df.columns); // ['fare', 'taxi_id'] — your order, not the file's
|
|
932
|
-
```
|
|
933
|
-
|
|
934
|
-
- **The result is in your order**, not the file's, matching `select('b', 'a')`.
|
|
935
|
-
- **An unknown field name throws**, and so does a repeated one or an empty list. Silently returning
|
|
936
|
-
fewer columns than you asked for is the bug this library is designed against.
|
|
937
|
-
- **`metadata` still describes the file**, not the projection: `getAllFieldMetadata()` reports every
|
|
938
|
-
field whether or not it was read, exactly as `NoOfRecords` stays the file's row count under
|
|
939
|
-
`maxRows`. `columns` is what says what was read.
|
|
940
|
-
- **A projection cannot make a corrupt file readable.** Every field's metadata is validated whether
|
|
941
|
-
or not it was selected.
|
|
942
|
-
|
|
943
|
-
#### `static iterate(path: string, options?: object): AsyncGenerator<QvdDataFrame>`
|
|
944
|
-
|
|
945
|
-
Reads a QVD in chunks. Takes every option `fromQvd` takes, plus `chunkSize` (default 100 000).
|
|
946
|
-
|
|
947
|
-
```javascript
|
|
948
|
-
for await (const chunk of QvdDataFrame.iterate('big.qvd', {chunkSize: 50_000})) {
|
|
949
|
-
process(chunk.data); // at most 50,000 rows
|
|
950
|
-
console.log(chunk.loadStats.offset); // where in the file this chunk starts
|
|
951
|
-
}
|
|
952
|
-
```
|
|
953
|
-
|
|
954
|
-
The file is opened, read and parsed **once**; only the index decode and the row building happen per
|
|
955
|
-
chunk. That is why this is a method rather than a loop of `fromQvd({offset, limit})` calls at your
|
|
956
|
-
call site: the symbol table has to be parsed in full whatever the chunk size — a stored index in the
|
|
957
|
-
last chunk can address the first symbol — so re-parsing it per chunk costs more than a plain load
|
|
958
|
-
rather than less.
|
|
959
|
-
|
|
960
|
-
What it bounds is **row materialisation**, which is what dominates a large read's heap. Measured on
|
|
961
|
-
the taxi fixture by binary-searching the smallest `--max-old-space-size` at which each read
|
|
962
|
-
completes, in a fresh process per rung:
|
|
963
|
-
|
|
964
|
-
| | Smallest heap it runs in | Smallest heap it is allowed in |
|
|
965
|
-
| ------------------------------- | -----------------------: | -----------------------------: |
|
|
966
|
-
| `fromQvd()`, all 1.7 M rows | **384 MB** | **512 MB** |
|
|
967
|
-
| `iterate({chunkSize: 125_000})` | **80 MB** | **96 MB** |
|
|
968
|
-
| `iterate({chunkSize: 250_000})` | 128 MB | 192 MB |
|
|
969
|
-
| `iterate({chunkSize: 500_000})` | 224 MB | 320 MB |
|
|
970
|
-
|
|
971
|
-
Every row is read in all four. The two columns answer different questions and both are worth
|
|
972
|
-
having: the first is measured with `memorySafetyFactor: 0`, so it is what the read physically
|
|
973
|
-
needs, and the second is the one you actually hit, because on default settings the memory guard
|
|
974
|
-
refuses below it. Quoting only the first would name a heap no default caller can reproduce.
|
|
975
|
-
|
|
976
|
-
Time is not the reason to use it — walking the whole file in chunks of 100 000 takes 593 ms against
|
|
977
|
-
674 ms for a single frame.
|
|
978
|
-
|
|
979
|
-
⚠️ **This is not constant-memory reading of an arbitrarily large file.** The symbol table is parsed
|
|
980
|
-
in full whatever the chunk size. On a high-cardinality file that table is the bulk of the cost, and
|
|
981
|
-
`readMetadata` is the only read that avoids it.
|
|
982
|
-
|
|
983
|
-
A window covering no rows yields nothing at all, rather than one empty frame, so a loop over an
|
|
984
|
-
exhausted offset simply does not run its body.
|
|
985
|
-
|
|
986
|
-
#### Read progress and cancellation
|
|
987
|
-
|
|
988
|
-
`onProgress` and `signal` work on `fromQvd`, `iterate` and `QvdColumnTable.fromQvd`. The progress
|
|
989
|
-
object is the same `{stage, current, total, percent}` shape that `toQvd` emits, so one progress bar
|
|
990
|
-
serves both directions.
|
|
991
|
-
|
|
992
|
-
```javascript
|
|
993
|
-
const controller = new AbortController();
|
|
994
|
-
setTimeout(() => controller.abort(), 30_000);
|
|
995
|
-
|
|
996
|
-
const df = await QvdDataFrame.fromQvd('huge.qvd', {
|
|
997
|
-
signal: controller.signal,
|
|
998
|
-
onProgress: ({stage, percent}) => process.stdout.write(`\r${stage}: ${percent}% `),
|
|
999
|
-
});
|
|
1000
|
-
```
|
|
1001
|
-
|
|
1002
|
-
Read stages, in order:
|
|
1003
|
-
|
|
1004
|
-
| Stage | Units | Notes |
|
|
1005
|
-
| ----------------- | -------------- | -------------------------------------------------- |
|
|
1006
|
-
| `read` | 0 → 1 | Pulling the bytes off disk. |
|
|
1007
|
-
| `header` | 0 → 1 | Parsing the XML header. |
|
|
1008
|
-
| `symbol-analysis` | fields | Only when the two-pass symbol-filtering path runs. |
|
|
1009
|
-
| `symbol-table` | fields | Parsing symbols. Counts selected fields only. |
|
|
1010
|
-
| `index-table` | fields | Decoding the bit-packed columns. |
|
|
1011
|
-
| `rows` | rows, every 1% | Building row arrays. Absent on a columnar read. |
|
|
1012
|
-
|
|
1013
|
-
Under `iterate`, `index-table` and `rows` repeat per chunk, and `rows` reports the position in the
|
|
1014
|
-
**whole window** rather than in the chunk — so it works as a progress bar across the iteration.
|
|
1015
|
-
|
|
1016
|
-
Cancellation rejects with `signal.reason`, exactly as `signal.throwIfAborted()` produces it: a
|
|
1017
|
-
`DOMException` named `AbortError` unless you called `abort(reason)` with your own error. That is
|
|
1018
|
-
deliberately **not** a `QvdError` — it is what `AbortSignal` means everywhere else in Node, and a
|
|
1019
|
-
library-specific abort error would be the one your code has to special-case. An already-aborted
|
|
1020
|
-
signal stops the read before it opens the file.
|
|
1021
|
-
|
|
1022
|
-
#### `static readMetadata(path: string, options?: object): Promise<object>`
|
|
1023
|
-
|
|
1024
|
-
Reads a QVD file's schema and header metadata without reading its data. The cost is the same
|
|
1025
|
-
whatever the file's size, because it stops at the XML header and never touches the symbol or
|
|
1026
|
-
index tables.
|
|
1027
|
-
|
|
1028
|
-
This is not the same as `fromQvd(path, {maxRows: 0})`. That loads no rows but still reads and
|
|
1029
|
-
parses the entire symbol table — 0.4 MB on a 38 MB file, but 15 MB on a high-cardinality one, and
|
|
1030
|
-
it grows with the data. Measured on a 200,000-row file where every value is distinct,
|
|
1031
|
-
`readMetadata` is **352× faster**, and unlike `{maxRows: 0}` it does not get slower as the file
|
|
1032
|
-
grows.
|
|
1033
|
-
|
|
1034
|
-
**Parameters:**
|
|
1035
|
-
|
|
1036
|
-
- `path` (string): The path to the QVD file.
|
|
1037
|
-
- `options` (object, optional):
|
|
1038
|
-
- `allowedDir` (string, optional): Base directory for file access validation, applied exactly as
|
|
1039
|
-
it is for `fromQvd`.
|
|
1040
|
-
- `onProgress` (function, optional): As on `fromQvd`. Only the `read` and `header` stages occur
|
|
1041
|
-
here — there are no symbols to parse and no rows to build.
|
|
1042
|
-
- `signal` (AbortSignal, optional): Cancels the read, rejecting with `signal.reason`.
|
|
1043
|
-
- `offset`, `limit`, `maxRows` and `fields` are accepted and ignored, so one options object can
|
|
1044
|
-
be passed to this and to a data read without stripping it.
|
|
1045
|
-
|
|
1046
|
-
**Returns** a plain object — deliberately not a `QvdDataFrame`, since one with `data: []` would be
|
|
1047
|
-
indistinguishable from an empty file at the call site:
|
|
1048
|
-
|
|
1049
|
-
| Property | Type | Description |
|
|
1050
|
-
| -------------- | ---------- | ----------------------------------------------------------------------------------- |
|
|
1051
|
-
| `columns` | `string[]` | Field names, in file order. |
|
|
1052
|
-
| `rowCount` | `number` | Rows the **file** declares. Nothing was loaded; this is not a count of rows read. |
|
|
1053
|
-
| `columnCount` | `number` | Number of fields. |
|
|
1054
|
-
| `fields` | `object[]` | Per-field metadata, same shape and order as `getFieldMetadata()` on a loaded frame. |
|
|
1055
|
-
| `fileMetadata` | `object` | Same shape as the `fileMetadata` accessor on a loaded frame. |
|
|
1056
|
-
| `metadata` | `object` | The raw `QvdTableHeader`, as `metadata` gives it. |
|
|
1057
|
-
|
|
1058
|
-
**Example:**
|
|
1059
|
-
|
|
1060
|
-
```javascript
|
|
1061
|
-
// What is in this file, without reading any of it
|
|
1062
|
-
const {columns, rowCount, fields} = await QvdDataFrame.readMetadata('sales.qvd');
|
|
1063
|
-
|
|
1064
|
-
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
1065
|
-
|
|
1066
|
-
for (const field of fields) {
|
|
1067
|
-
console.log(`${field.fieldName}: ${field.noOfSymbols} distinct values`);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
// Decide whether it is worth loading at all
|
|
1071
|
-
if (rowCount < 1_000_000) {
|
|
1072
|
-
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
1073
|
-
}
|
|
1074
|
-
```
|
|
1075
|
-
|
|
1076
|
-
### QvdColumnTable
|
|
1077
|
-
|
|
1078
|
-
`QvdColumnTable` reads a QVD **as columns instead of rows**. It uses the same decoder and the
|
|
1079
|
-
same symbol resolution as `QvdDataFrame.fromQvd` — it simply stops before building rows, and
|
|
1080
|
-
keeps what the decoder already produced: one `Int32Array` of codes per field, and one resolved
|
|
1081
|
-
value per _distinct_ symbol.
|
|
1082
|
-
|
|
1083
|
-
Measured on the bundled 1.7 M × 20 taxi fixture, each in its own process so only one
|
|
1084
|
-
representation is alive:
|
|
1085
|
-
|
|
1086
|
-
| | Live memory | Sum one column |
|
|
1087
|
-
| ------------------------ | ----------- | -------------- |
|
|
1088
|
-
| `QvdDataFrame.fromQvd` | 385 MiB | 26.8 ms |
|
|
1089
|
-
| `QvdColumnTable.fromQvd` | **141 MiB** | **3.6 ms** |
|
|
1090
|
-
|
|
1091
|
-
Use it when you want to scan or aggregate a few columns of a large file. Use `QvdDataFrame` when
|
|
1092
|
-
you want rows. It does not convert to a data frame, deliberately — holding both representations
|
|
1093
|
-
is the one configuration in which this costs more than it saves, and re-reading a file as rows
|
|
1094
|
-
costs no more than reading it as rows always did.
|
|
1095
|
-
|
|
1096
|
-
```javascript
|
|
1097
|
-
import {QvdColumnTable} from 'qvdjs';
|
|
1098
|
-
|
|
1099
|
-
const table = await QvdColumnTable.fromQvd('trips.qvd');
|
|
1100
|
-
const fare = table.column('fare');
|
|
1101
|
-
|
|
1102
|
-
// The fastest scan: both sides are contiguous typed arrays and the dictionary fits in cache.
|
|
1103
|
-
const codes = fare.codes;
|
|
1104
|
-
const values = fare.numericSymbols();
|
|
1105
|
-
let total = 0;
|
|
1106
|
-
|
|
1107
|
-
for (let row = 0; row < codes.length; row++) {
|
|
1108
|
-
const code = codes[row];
|
|
1109
|
-
if (code >= 0) {
|
|
1110
|
-
const value = values[code];
|
|
1111
|
-
if (!Number.isNaN(value)) total += value;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// Or, more simply
|
|
1116
|
-
for (const value of fare) {
|
|
1117
|
-
/* number | string | null */
|
|
1118
|
-
}
|
|
1119
|
-
console.log(fare.at(0), fare.toArray().length);
|
|
1120
|
-
```
|
|
1121
|
-
|
|
1122
|
-
**`QvdColumnTable`** — `static fromQvd(path, options)`, `column(name)`, `columns`, `rowCount`,
|
|
1123
|
-
`shape`, `metadata`, `loadStats`.
|
|
1124
|
-
|
|
1125
|
-
`fromQvd` takes **exactly the same options** as `QvdDataFrame.fromQvd` — `offset`, `limit`,
|
|
1126
|
-
`maxRows`, `fields`, `onProgress`, `signal`, `allowedDir`, `memorySafetyFactor`,
|
|
1127
|
-
`symbolFilteringThreshold` — with the same meanings. They are two answers about the same file, not
|
|
1128
|
-
two features, so `{offset, limit}` is how you page through a file columnwise:
|
|
1129
|
-
|
|
1130
|
-
```javascript
|
|
1131
|
-
const table = await QvdColumnTable.fromQvd('trips.qvd', {
|
|
1132
|
-
fields: ['fare', 'tips'],
|
|
1133
|
-
offset: 1_000_000,
|
|
1134
|
-
limit: 100_000,
|
|
1135
|
-
});
|
|
1136
|
-
```
|
|
1137
|
-
|
|
1138
|
-
There is no columnar `iterate()`, deliberately: chunking exists to bound row materialisation, and a
|
|
1139
|
-
columnar read materialises no rows. `{offset, limit}` covers paging.
|
|
1140
|
-
|
|
1141
|
-
**`QvdColumn`** — what `column(name)` returns, frozen:
|
|
1142
|
-
|
|
1143
|
-
| Member | Type | Description |
|
|
1144
|
-
| -------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
1145
|
-
| `name` | `string` | The field name. |
|
|
1146
|
-
| `length` | `number` | Rows in the column. |
|
|
1147
|
-
| `codes` | `Int32Array` | One stored index per row. **Negative means NULL.** The table's own array — treat as read-only. |
|
|
1148
|
-
| `symbols` | `ReadonlyArray<any>` | The distinct values, indexed by the codes. One entry per distinct value, not per row. |
|
|
1149
|
-
| `at(row)` | `any` | The value of one row, or `null`. |
|
|
1150
|
-
| `[Symbol.iterator]` | | Iterates values without materialising the column. |
|
|
1151
|
-
| `toArray()` | `Array<any>` | Lossless; cell-for-cell what `data[row][column]` holds. Caller-owned. |
|
|
1152
|
-
| `numericSymbols()` | `Float64Array` | The **dictionary** as numbers, NaN for non-numeric. One entry per distinct value — 17 KB for a 1.7 M-row column. |
|
|
1153
|
-
| `toFloat64Array(options?)` | `Float64Array` | One number **per row**. Lossy, so it throws on a non-numeric value unless you pass `{onNonNumeric: 'nan'}`. |
|
|
1154
|
-
|
|
1155
|
-
`toFloat64Array` refuses by default rather than writing NaN because on real QVDs the lossy case
|
|
1156
|
-
is common, not exceptional: in the bundled taxi fixture **no column is strictly numeric** —
|
|
1157
|
-
`dropoff_census_tract` is 43 % empty strings, and four columns are 100 % strings. Silently
|
|
1158
|
-
turning two fifths of a column into NaN would erase Qlik's distinction between a blank and a
|
|
1159
|
-
number. `numericSymbols()` is the cheap conversion; `toFloat64Array()` is the expensive one.
|
|
1160
|
-
|
|
1161
|
-
#### `static fromDict(dict: object): Promise<QvdDataFrame>`
|
|
1162
|
-
|
|
1163
|
-
The static method `QvdDataFrame.fromDict` constructs a data frame from a dictionary. The dictionary must contain the columns and
|
|
1164
|
-
the actual data as properties. The columns property is an array of strings that contains the names of the fields in the QVD file.
|
|
1165
|
-
The data property is an array of arrays that contains the actual data records. The order of the values in the inner arrays
|
|
1166
|
-
corresponds to the order of the fields in the QVD file.
|
|
1167
|
-
|
|
1168
|
-
#### `head(n: number): QvdDataFrame`
|
|
1169
|
-
|
|
1170
|
-
The method `head` returns the first `n` rows of the data frame.
|
|
1171
|
-
|
|
1172
|
-
#### `tail(n: number): QvdDataFrame`
|
|
1173
|
-
|
|
1174
|
-
The method `tail` returns the last `n` rows of the data frame.
|
|
1175
|
-
|
|
1176
|
-
#### `rows(...args: number): QvdDataFrame`
|
|
1177
|
-
|
|
1178
|
-
The method `rows` returns a new data frame that contains only the specified rows.
|
|
1179
|
-
|
|
1180
|
-
#### `at(row: number, column: string): any`
|
|
1181
|
-
|
|
1182
|
-
The method `at` returns the value at the specified row and column.
|
|
1183
|
-
|
|
1184
|
-
#### `select(...args: string): QvdDataFrame`
|
|
1185
|
-
|
|
1186
|
-
The method `select` returns a new data frame that contains only the specified columns.
|
|
1187
|
-
|
|
1188
|
-
#### `toDict(): Promise<object>`
|
|
1189
|
-
|
|
1190
|
-
The method `toDict` returns the data frame as a dictionary. The dictionary contains the columns and the
|
|
1191
|
-
actual data as properties. The columns property is an array of strings that contains the names of the
|
|
1192
|
-
fields in the QVD file. The data property is an array of arrays that contains the actual data records.
|
|
1193
|
-
The order of the values in the inner arrays corresponds to the order of the fields in the QVD file.
|
|
1194
|
-
|
|
1195
|
-
#### `toQvd(path: string, options?: object): Promise<void>`
|
|
1196
|
-
|
|
1197
|
-
The method `toQvd` writes the data frame to a QVD file at the specified path.
|
|
1198
|
-
|
|
1199
|
-
**Parameters:**
|
|
1200
|
-
|
|
1201
|
-
- `path` (string): The path where the QVD file should be written.
|
|
1202
|
-
- `options` (object, optional): Writing options
|
|
1203
|
-
- `allowedDir` (string, optional): Base directory for file write 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.
|
|
1204
|
-
- `onProgress` (function, optional): Progress callback function for tracking write operations. Receives progress updates during symbol table building, index table building, header generation, and data writing.
|
|
1205
|
-
|
|
1206
|
-
**Progress Callback:**
|
|
1207
|
-
|
|
1208
|
-
The `onProgress` callback receives an object with the following properties:
|
|
1209
|
-
|
|
1210
|
-
- `stage` (string): Current operation stage - `'symbol-table'`, `'index-table'`, `'header'`, or `'write'`
|
|
1211
|
-
- `current` (number): Current progress value (e.g., rows processed, columns completed)
|
|
1212
|
-
- `total` (number): Total progress value
|
|
1213
|
-
- `percent` (number): Progress percentage (0-100)
|
|
1214
|
-
|
|
1215
|
-
This is particularly useful for large QVD files where write operations can take significant time.
|
|
1216
|
-
|
|
1217
|
-
**Examples:**
|
|
1218
|
-
|
|
1219
|
-
```javascript
|
|
1220
|
-
// Write to file (default behavior)
|
|
1221
|
-
await df.toQvd('output/data.qvd');
|
|
1222
|
-
|
|
1223
|
-
// Write with security restriction (recommended for production)
|
|
1224
|
-
await df.toQvd('processed/data.qvd', {
|
|
1225
|
-
allowedDir: '/var/output/qvd-files',
|
|
1226
|
-
});
|
|
1227
|
-
|
|
1228
|
-
// Write with progress tracking for large files
|
|
1229
|
-
await df.toQvd('large-output.qvd', {
|
|
1230
|
-
onProgress: (progress) => {
|
|
1231
|
-
console.log(`${progress.stage}: ${progress.percent}% (${progress.current}/${progress.total})`);
|
|
1232
|
-
},
|
|
1233
|
-
});
|
|
1234
|
-
|
|
1235
|
-
// Write with detailed progress bar
|
|
1236
|
-
await df.toQvd('data.qvd', {
|
|
1237
|
-
onProgress: (progress) => {
|
|
1238
|
-
const bar = '█'.repeat(Math.floor(progress.percent / 2)) + '░'.repeat(50 - Math.floor(progress.percent / 2));
|
|
1239
|
-
process.stdout.write(`\r[${progress.stage}] ${bar} ${progress.percent}%`);
|
|
1240
|
-
if (progress.current === progress.total) console.log(' ✓');
|
|
1241
|
-
},
|
|
1242
|
-
});
|
|
1243
|
-
```
|
|
1244
|
-
|
|
1245
|
-
#### `getFieldMetadata(fieldName: string): object | null`
|
|
1246
|
-
|
|
1247
|
-
The method `getFieldMetadata` returns the metadata for a specific field/column from the QVD header. Returns null if the field is not found or metadata is not available.
|
|
1248
|
-
|
|
1249
|
-
The returned object contains:
|
|
1250
|
-
|
|
1251
|
-
- `fieldName`: Name of the field
|
|
1252
|
-
- `bitOffset`: Bit offset in the index table
|
|
1253
|
-
- `bitWidth`: Bit width in the index table
|
|
1254
|
-
- `bias`: Bias value for index calculation
|
|
1255
|
-
- `noOfSymbols`: Number of unique symbols/values
|
|
1256
|
-
- `offset`: Byte offset in the symbol table
|
|
1257
|
-
- `length`: Byte length in the symbol table
|
|
1258
|
-
- `comment`: Field comment (modifiable)
|
|
1259
|
-
- `numberFormat`: Number format settings (modifiable)
|
|
1260
|
-
- `tags`: Field tags (modifiable)
|
|
1261
|
-
|
|
1262
|
-
Note: Properties like `offset`, `length`, `bitOffset`, `bitWidth`, `bias`, and `noOfSymbols` are immutable and relate to internal data storage.
|
|
1263
|
-
|
|
1264
|
-
#### `getAllFieldMetadata(): object[]`
|
|
1265
|
-
|
|
1266
|
-
The method `getAllFieldMetadata` returns an array of metadata objects for all fields in the data frame. Each object has the same structure as returned by `getFieldMetadata`.
|
|
1267
|
-
|
|
1268
|
-
#### `setFileMetadata(metadata: object): void`
|
|
1269
|
-
|
|
1270
|
-
The method `setFileMetadata` allows modifying file-level metadata. Only modifiable properties are updated; immutable properties related to data storage are ignored.
|
|
1271
|
-
|
|
1272
|
-
Modifiable properties:
|
|
1273
|
-
|
|
1274
|
-
- `qvBuildNo`: QlikView build number
|
|
1275
|
-
- `creatorDoc`: Document GUID that created the QVD
|
|
1276
|
-
- `createUtcTime`: Creation timestamp
|
|
1277
|
-
- `sourceCreateUtcTime`: Source creation timestamp
|
|
1278
|
-
- `sourceFileUtcTime`: Source file timestamp
|
|
1279
|
-
- `sourceFileSize`: Source file size
|
|
1280
|
-
- `staleUtcTime`: Stale timestamp
|
|
1281
|
-
- `tableName`: Table name
|
|
1282
|
-
- `compression`: Compression method
|
|
1283
|
-
- `comment`: Table comment
|
|
1284
|
-
- `encryptionInfo`: Encryption information
|
|
1285
|
-
- `tableTags`: Table tags
|
|
1286
|
-
- `profilingData`: Profiling data
|
|
1287
|
-
- `lineage`: Data lineage information
|
|
1288
|
-
|
|
1289
|
-
Immutable properties (cannot be modified):
|
|
1290
|
-
|
|
1291
|
-
- `noOfRecords`: Number of records
|
|
1292
|
-
- `recordByteSize`: Record byte size
|
|
1293
|
-
- `offset`: Byte offset in file
|
|
1294
|
-
- `length`: Byte length in file
|
|
1295
|
-
|
|
1296
|
-
#### `setFieldMetadata(fieldName: string, metadata: object): void`
|
|
1297
|
-
|
|
1298
|
-
The method `setFieldMetadata` allows modifying field-level metadata for a specific field. Only modifiable properties are updated; immutable properties related to data storage are ignored.
|
|
1299
|
-
|
|
1300
|
-
Modifiable properties:
|
|
1301
|
-
|
|
1302
|
-
- `comment`: Field comment/description
|
|
1303
|
-
- `numberFormat`: Number format settings (Type, nDec, UseThou, Fmt, Dec, Thou)
|
|
1304
|
-
- `tags`: Field tags (typically used for field classification)
|
|
1305
|
-
|
|
1306
|
-
Immutable properties (cannot be modified):
|
|
1307
|
-
|
|
1308
|
-
- `offset`: Byte offset in symbol table
|
|
1309
|
-
- `length`: Byte length in symbol table
|
|
1310
|
-
- `bitOffset`: Bit offset in index table
|
|
1311
|
-
- `bitWidth`: Bit width in index table
|
|
1312
|
-
- `bias`: Bias value
|
|
1313
|
-
- `noOfSymbols`: Number of symbols
|
|
1314
|
-
|
|
1315
|
-
### The low-level exports
|
|
1316
|
-
|
|
1317
|
-
`src/index.js` also exports `QvdFileReader`, `QvdFileWriter` and `QvdSymbol`. **`QvdDataFrame` and
|
|
1318
|
-
`QvdColumnTable` are the surface you should use**; these three are the building blocks they are
|
|
1319
|
-
built from, and they are documented here because an exported class that appears nowhere in the
|
|
1320
|
-
documentation is the worst of both worlds — nobody can tell whether it is supported, and removing
|
|
1321
|
-
it later breaks whoever guessed that it was.
|
|
1322
|
-
|
|
1323
|
-
They stay exported. Removing them would be a breaking change for a saving of nothing, and
|
|
1324
|
-
`QvdFileReader` in particular is where chunked iteration actually lives.
|
|
217
|
+
Honest boundaries rather than an issue list — these are the ones that change what you can do.
|
|
1325
218
|
|
|
1326
|
-
|
|
|
1327
|
-
|
|
|
1328
|
-
|
|
|
1329
|
-
|
|
|
1330
|
-
|
|
|
219
|
+
| Limitation | What it means in practice |
|
|
220
|
+
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
221
|
+
| **Full loads stop at 2 GiB** | Use a windowed read or `iterate()` above that. Failure is a raw Node `RangeError`, not a `QvdError`. |
|
|
222
|
+
| **The symbol table is always parsed in full** | `{offset, limit}`, `iterate()` and `QvdColumnTable` all avoid materialising rows, but none is constant-memory in the size of a high-cardinality file. |
|
|
223
|
+
| **Writes are not atomic** | `toQvd()` writes in place. A failure part-way through leaves the previous file damaged. Write to a temporary path and rename if that matters. |
|
|
224
|
+
| **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. |
|
|
1331
225
|
|
|
1332
|
-
|
|
226
|
+
→ [What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v1.0/overview/what-a-round-trip-preserves/)
|
|
1333
227
|
|
|
1334
228
|
## Documentation
|
|
1335
229
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
### For Users
|
|
1339
|
-
|
|
1340
|
-
- **[README.md](./README.md)** (this file) - Quick start guide and API reference
|
|
1341
|
-
- **[QVD_FORMAT.md](./docs/QVD_FORMAT.md)** - Complete QVD file format specification
|
|
1342
|
-
- Binary structure details
|
|
1343
|
-
- Symbol type encodings
|
|
1344
|
-
- Bit packing algorithms
|
|
1345
|
-
- Example file breakdowns
|
|
1346
|
-
|
|
1347
|
-
### For Contributors
|
|
1348
|
-
|
|
1349
|
-
- **[CONTRIBUTING.md](./docs/CONTRIBUTING.md)** - How to contribute to the project
|
|
1350
|
-
- Development setup
|
|
1351
|
-
- Code style guidelines
|
|
1352
|
-
- Commit message conventions
|
|
1353
|
-
- Pull request process
|
|
1354
|
-
- Bug reporting and feature requests
|
|
1355
|
-
|
|
1356
|
-
- **[DEVELOPMENT.md](./docs/DEVELOPMENT.md)** - Technical development guide
|
|
1357
|
-
- Architecture overview
|
|
1358
|
-
- Implementation details
|
|
1359
|
-
- Design patterns
|
|
1360
|
-
- Performance considerations
|
|
1361
|
-
- Error handling strategies
|
|
1362
|
-
- Debugging tips
|
|
1363
|
-
|
|
1364
|
-
- **[ARCHITECTURE.md](./docs/ARCHITECTURE.md)** - High-level architecture
|
|
1365
|
-
- Component diagrams
|
|
1366
|
-
- Class relationships
|
|
1367
|
-
- Data flow visualization
|
|
1368
|
-
- Design decisions and rationale
|
|
1369
|
-
- Extension points
|
|
1370
|
-
- Future considerations
|
|
1371
|
-
|
|
1372
|
-
- **[docs/TESTING.md](./docs/TESTING.md)** - Comprehensive testing guide
|
|
1373
|
-
- Testing philosophy and strategy
|
|
1374
|
-
- How to write unit and integration tests
|
|
1375
|
-
- Performance testing guidelines
|
|
1376
|
-
- Coverage targets
|
|
1377
|
-
- Multi-platform testing setup
|
|
1378
|
-
|
|
1379
|
-
### Quick Links by Task
|
|
1380
|
-
|
|
1381
|
-
| I want to... | See... |
|
|
1382
|
-
| ----------------------- | ----------------------------------------------------------------------------------- |
|
|
1383
|
-
| Use the library | [README.md](./README.md) - Usage section |
|
|
1384
|
-
| Understand QVD format | [QVD_FORMAT.md](./docs/QVD_FORMAT.md) |
|
|
1385
|
-
| Report a bug | [CONTRIBUTING.md](./docs/CONTRIBUTING.md#reporting-bugs) |
|
|
1386
|
-
| Suggest a feature | [CONTRIBUTING.md](./docs/CONTRIBUTING.md#suggesting-features) |
|
|
1387
|
-
| Contribute code | [CONTRIBUTING.md](./docs/CONTRIBUTING.md) + [DEVELOPMENT.md](./docs/DEVELOPMENT.md) |
|
|
1388
|
-
| Understand architecture | [ARCHITECTURE.md](./docs/ARCHITECTURE.md) |
|
|
1389
|
-
| Write tests | [docs/TESTING.md](./docs/TESTING.md) |
|
|
1390
|
-
| Debug an issue | [DEVELOPMENT.md](./docs/DEVELOPMENT.md#debugging-tips) |
|
|
1391
|
-
|
|
1392
|
-
## Testing
|
|
1393
|
-
|
|
1394
|
-
qvdjs has comprehensive test coverage with automated multi-platform testing. For detailed information about the testing infrastructure, including:
|
|
1395
|
-
|
|
1396
|
-
- Test architecture and coverage breakdown
|
|
1397
|
-
- Multi-platform support (Windows, macOS, Linux)
|
|
1398
|
-
- Security testing approach
|
|
1399
|
-
- Self-hosted runner setup
|
|
1400
|
-
- Performance benchmarking
|
|
1401
|
-
|
|
1402
|
-
See the **[Testing Documentation](./docs/README.md)** in the `docs/` directory.
|
|
1403
|
-
|
|
1404
|
-
Quick links:
|
|
1405
|
-
|
|
1406
|
-
- **[TESTING.md](./docs/TESTING.md)** - Test architecture, coverage and the multi-platform setup
|
|
1407
|
-
- **[TESTING_WITH_LARGE_FILES.md](./docs/TESTING_WITH_LARGE_FILES.md)** - Working with the bundled
|
|
1408
|
-
multi-megabyte fixtures
|
|
1409
|
-
|
|
1410
|
-
### Running Tests
|
|
230
|
+
**[qvdjs.ptarmiganlabs.com](https://qvdjs.ptarmiganlabs.com)** is the documentation site, and it is
|
|
231
|
+
where everything above is covered properly:
|
|
1411
232
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
233
|
+
| | |
|
|
234
|
+
| ------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
235
|
+
| [Getting started](https://qvdjs.ptarmiganlabs.com/v1.0/getting-started/) | Install, choose an entry point, first read and write |
|
|
236
|
+
| [Guides](https://qvdjs.ptarmiganlabs.com/v1.0/guides/) | One page per task, runnable sample code |
|
|
237
|
+
| [Concepts](https://qvdjs.ptarmiganlabs.com/v1.0/concepts/) | The QVD format, symbols and duals, bit stuffing, the memory model |
|
|
238
|
+
| [Reference](https://qvdjs.ptarmiganlabs.com/v1.0/reference/) | Every class, method, option and error |
|
|
239
|
+
| [Performance](https://qvdjs.ptarmiganlabs.com/v1.0/performance/) | Measured baselines, and how to read them |
|
|
240
|
+
| [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v1.0/troubleshooting/) | What each failure means, and what to do about it |
|
|
1415
241
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
# Run specific test file
|
|
1420
|
-
npm test -- __tests__/reader.test.js
|
|
1421
|
-
```
|
|
242
|
+
Benchmarks run weekly and publish to
|
|
243
|
+
[ptarmiganlabs.github.io/qvdjs](https://ptarmiganlabs.github.io/qvdjs/).
|
|
1422
244
|
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
## Contributing
|
|
1426
|
-
|
|
1427
|
-
We welcome contributions! When contributing to this project, please follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages. This enables automatic version management and changelog generation.
|
|
245
|
+
## Contributors
|
|
1428
246
|
|
|
1429
|
-
|
|
247
|
+
- [Göran Sander](https://github.com/mountaindude) and [Ptarmigan Labs](https://github.com/ptarmiganlabs) —
|
|
248
|
+
creator of qvdjs. Added lazy loading, columnar and chunked reads, header-only metadata reads,
|
|
249
|
+
exposed QVD header metadata, ESM/CJS support, memory safety limits, security hardening,
|
|
250
|
+
multi-platform testing and the automated release process.
|
|
251
|
+
- [Constantin Müller](https://mueller-constantin.de) — author of the original
|
|
252
|
+
[qvd4js](https://github.com/MuellerConstantin/qvd4js) library, from which qvdjs inherited initial
|
|
253
|
+
versions of the core read and write functions.
|
|
1430
254
|
|
|
1431
|
-
##
|
|
255
|
+
## Licence
|
|
1432
256
|
|
|
1433
|
-
|
|
1434
|
-
- [Constantin Müller](https://mueller-constantin.de) - Author of the original qvd4js library, from which qvdjs inherited initial versions of the core functions for reading and writing QVD files.
|
|
257
|
+
[MIT](https://opensource.org/licenses/MIT) © Ptarmigan Labs
|