qvdjs 0.10.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -1101
- package/dist/index.cjs +2612 -735
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2610 -737
- package/dist/index.js.map +1 -1
- package/img/logo/qvdjs_logo-512.png +0 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,158 +1,63 @@
|
|
|
1
|
+
<!-- Served from the npm tarball via jsDelivr rather than by relative path: this
|
|
2
|
+
repository is private, and npmjs.com resolves relative README images against
|
|
3
|
+
raw.githubusercontent.com, where they 404. See CLAUDE.md, "Logo assets". -->
|
|
4
|
+
<p align="center">
|
|
5
|
+
<img src="https://cdn.jsdelivr.net/npm/qvdjs/img/logo/qvdjs_logo-512.png" alt="qvdjs logo" width="200" />
|
|
6
|
+
</p>
|
|
7
|
+
|
|
1
8
|
# qvdjs
|
|
2
9
|
|
|
3
|
-
>
|
|
4
|
-
|
|
5
|
-
## ⚠️ Important Disclaimer
|
|
6
|
-
|
|
7
|
-
**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.
|
|
8
|
-
|
|
9
|
-
**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:
|
|
10
|
-
|
|
11
|
-
- Test thoroughly with their specific use cases
|
|
12
|
-
- Validate output files in their Qlik environment
|
|
13
|
-
- Report any issues or inconsistencies discovered
|
|
14
|
-
|
|
15
|
-
The library works with real-world QVD files and maintains compatibility with Qlik products, but it is an independent, community-driven implementation.
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
The _qvdjs_ library provides a simple API for reading and writing Qlik View Data (QVD) files in JavaScript.
|
|
20
|
-
It parses the binary QVD format into a JavaScript object structure and back again, and is written for Node.js
|
|
21
|
-
(20.10 or newer) exclusively.
|
|
22
|
-
|
|
23
|
-
**What it does well:**
|
|
24
|
-
|
|
25
|
-
- **Large files without loading them.** Pass `maxRows` and the library reads only the header, the symbols those
|
|
26
|
-
rows actually reference, and that slice of the index table. On a file whose symbol table is large because its
|
|
27
|
-
fields hold mostly unique values, this is the difference between minutes and under a second — and it is the
|
|
28
|
-
only way to read a file above 2 GiB at all. See [Lazy Loading](#lazy-loading).
|
|
29
|
-
- **Writing at real row counts.** `toQvd()` handles hundreds of thousands of rows, with optional progress
|
|
30
|
-
callbacks for long writes. Files it produces open in Qlik Sense and QlikView.
|
|
31
|
-
- **Refusing rather than crashing.** A load too large for the process throws a catchable `QvdValidationError`
|
|
32
|
-
naming the limit it hit and a row count that would fit, instead of a `FATAL ERROR: Reached heap limit` that no
|
|
33
|
-
`try`/`catch` can intercept. The estimate accounts for the rows and columns being materialised, not just the
|
|
34
|
-
symbol table, and the budget comes from the V8 heap ceiling and any container memory limit — the two things
|
|
35
|
-
that actually kill a process. The suggested row count is checked against the same estimate before being
|
|
36
|
-
offered, so following it works. See [QVD File Size Limitations](#qvd-file-size-limitations).
|
|
37
|
-
- **Corrupt files are detected, not silently misread.** Truncated index tables, missing header delimiters and
|
|
38
|
-
out-of-range offsets raise typed errors rather than returning short or fabricated data.
|
|
39
|
-
- **Path traversal protection by default.** File access is confined to the working directory unless you widen it,
|
|
40
|
-
and containment is decided by the filesystem — symlinks are resolved, so a link inside the allowed directory
|
|
41
|
-
pointing outside it is refused. See [Security Considerations](#security-considerations).
|
|
42
|
-
|
|
43
|
-
**What it does not do yet** is worth knowing before you start: see
|
|
44
|
-
[Known limitations](#known-limitations).
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
- [qvdjs](#qvdjs)
|
|
49
|
-
- [⚠️ Important Disclaimer](#️-important-disclaimer)
|
|
50
|
-
- [Install](#install)
|
|
51
|
-
- [Usage](#usage)
|
|
52
|
-
- [Lazy Loading](#lazy-loading)
|
|
53
|
-
- [Important: Symbol Table and High-Cardinality Fields](#important-symbol-table-and-high-cardinality-fields)
|
|
54
|
-
- [Performance Optimizations](#performance-optimizations)
|
|
55
|
-
- [QVD File Size Limitations](#qvd-file-size-limitations)
|
|
56
|
-
- [The 2 GiB boundary](#the-2-gib-boundary)
|
|
57
|
-
- [Why Safety Limits Exist](#why-safety-limits-exist)
|
|
58
|
-
- [Writing QVD files](#writing-qvd-files)
|
|
59
|
-
- [Progress tracking](#progress-tracking)
|
|
60
|
-
- [Working with Metadata](#working-with-metadata)
|
|
61
|
-
- [Security Considerations](#security-considerations)
|
|
62
|
-
- [Known limitations](#known-limitations)
|
|
63
|
-
- [QVD File Format](#qvd-file-format)
|
|
64
|
-
- [XML Header](#xml-header)
|
|
65
|
-
- [Symbol Table](#symbol-table)
|
|
66
|
-
- [Index Table](#index-table)
|
|
67
|
-
- [Empty QVD Files](#empty-qvd-files)
|
|
68
|
-
- [API Documentation](#api-documentation)
|
|
69
|
-
- [QvdDataFrame](#qvddataframe)
|
|
70
|
-
- [`static fromQvd(path: string, options?: object): Promise<QvdDataFrame>`](#static-fromqvdpath-string-options-object-promiseqvddataframe)
|
|
71
|
-
- [`static fromDict(dict: object): Promise<QvdDataFrame>`](#static-fromdictdict-object-promiseqvddataframe)
|
|
72
|
-
- [`head(n: number): QvdDataFrame`](#headn-number-qvddataframe)
|
|
73
|
-
- [`tail(n: number): QvdDataFrame`](#tailn-number-qvddataframe)
|
|
74
|
-
- [`rows(...args: number): QvdDataFrame`](#rowsargs-number-qvddataframe)
|
|
75
|
-
- [`at(row: number, column: string): any`](#atrow-number-column-string-any)
|
|
76
|
-
- [`select(...args: string): QvdDataFrame`](#selectargs-string-qvddataframe)
|
|
77
|
-
- [`toDict(): Promise<object>`](#todict-promiseobject)
|
|
78
|
-
- [`toQvd(path: string, options?: object): Promise<void>`](#toqvdpath-string-options-object-promisevoid)
|
|
79
|
-
- [`getFieldMetadata(fieldName: string): object | null`](#getfieldmetadatafieldname-string-object--null)
|
|
80
|
-
- [`getAllFieldMetadata(): object[]`](#getallfieldmetadata-object)
|
|
81
|
-
- [`setFileMetadata(metadata: object): void`](#setfilemetadatametadata-object-void)
|
|
82
|
-
- [`setFieldMetadata(fieldName: string, metadata: object): void`](#setfieldmetadatafieldname-string-metadata-object-void)
|
|
83
|
-
- [Documentation](#documentation)
|
|
84
|
-
- [For Users](#for-users)
|
|
85
|
-
- [For Contributors](#for-contributors)
|
|
86
|
-
- [Quick Links by Task](#quick-links-by-task)
|
|
87
|
-
- [Testing](#testing)
|
|
88
|
-
- [Running Tests](#running-tests)
|
|
89
|
-
- [Contributing](#contributing)
|
|
90
|
-
- [Contributors](#contributors)
|
|
91
|
-
|
|
92
|
-
---
|
|
93
|
-
|
|
94
|
-
## Install
|
|
95
|
-
|
|
96
|
-
_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).
|
|
97
|
-
Before installing this library, download and install Node.js.
|
|
98
|
-
|
|
99
|
-
You can get _qvdjs_ using the following command:
|
|
10
|
+
> Read and write Qlik Sense and QlikView (QVD) files from Node.js
|
|
100
11
|
|
|
101
12
|
```bash
|
|
102
|
-
npm install qvdjs
|
|
13
|
+
npm install qvdjs
|
|
103
14
|
```
|
|
104
15
|
|
|
105
|
-
**Module Format Support:**
|
|
106
|
-
This library is published as a **dual ESM/CJS package**, providing full compatibility with both modern ES modules and traditional CommonJS environments:
|
|
107
|
-
|
|
108
|
-
- ✅ **ESM (ES Modules)**: Native `import` statements in Node.js and modern bundlers
|
|
109
|
-
- ✅ **CommonJS**: Traditional `require()` for compatibility with older Node.js projects
|
|
110
|
-
- ✅ **Bundlers**: Works with Webpack, Vite, Rollup, esbuild, and other modern build tools
|
|
111
|
-
|
|
112
|
-
**Usage Examples:**
|
|
113
|
-
|
|
114
16
|
```javascript
|
|
115
|
-
// ESM (ES Modules) - Modern Node.js and TypeScript
|
|
116
17
|
import {QvdDataFrame} from 'qvdjs';
|
|
117
18
|
|
|
118
|
-
|
|
119
|
-
|
|
19
|
+
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
20
|
+
console.log(df.shape); // [ 1705805, 20 ]
|
|
21
|
+
console.log(df.head(5));
|
|
120
22
|
```
|
|
121
23
|
|
|
122
|
-
|
|
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.
|
|
123
26
|
|
|
124
|
-
|
|
27
|
+
📖 **[Full documentation at qvdjs.ptarmiganlabs.com](https://qvdjs.ptarmiganlabs.com)** — guides,
|
|
28
|
+
API reference, the QVD format explained, measured performance, and troubleshooting.
|
|
125
29
|
|
|
126
|
-
|
|
30
|
+
## ⚠️ This library is based on reverse engineering
|
|
127
31
|
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
130
34
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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/v0.10/overview/what-a-round-trip-preserves/).
|
|
137
41
|
|
|
138
|
-
|
|
42
|
+
## Four ways to open a file
|
|
139
43
|
|
|
140
|
-
`fromQvd` is the general one, and often not the one you want. A QVD is an XML header, then a
|
|
141
|
-
|
|
142
|
-
far into the file a read has to go
|
|
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.
|
|
143
48
|
|
|
144
|
-
| You want
|
|
145
|
-
|
|
|
146
|
-
| Rows, to index, iterate or write back | `QvdDataFrame.fromQvd(path)`
|
|
147
|
-
|
|
|
148
|
-
|
|
|
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 |
|
|
149
55
|
|
|
150
56
|
```javascript
|
|
151
57
|
import {QvdDataFrame, QvdColumnTable} from 'qvdjs';
|
|
152
58
|
|
|
153
59
|
// What is in this file? Costs the same whether it is 20 KB or 20 GB.
|
|
154
60
|
const {columns, rowCount} = await QvdDataFrame.readMetadata('sales.qvd');
|
|
155
|
-
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
156
61
|
|
|
157
62
|
// Sum one column without ever building a row.
|
|
158
63
|
const table = await QvdColumnTable.fromQvd('sales.qvd');
|
|
@@ -161,1044 +66,192 @@ for (const value of table.column('amount')) {
|
|
|
161
66
|
if (typeof value === 'number') total += value;
|
|
162
67
|
}
|
|
163
68
|
|
|
69
|
+
// Every row of a file that will not fit, a chunk at a time.
|
|
70
|
+
for await (const chunk of QvdDataFrame.iterate('sales.qvd', {chunkSize: 50_000})) {
|
|
71
|
+
process(chunk.data);
|
|
72
|
+
}
|
|
73
|
+
|
|
164
74
|
// Rows, when you want rows.
|
|
165
75
|
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
166
|
-
console.log(df.head(5));
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
Reaching for `fromQvd` when you wanted one of the other two is the common mistake, and
|
|
170
|
-
`fromQvd(path, {maxRows: 0})` is not a substitute for `readMetadata`: it loads no rows but still
|
|
171
|
-
reads and parses the whole symbol table, which grows with the data. See
|
|
172
|
-
[QvdColumnTable](#qvdcolumntable) and the [API Documentation](#api-documentation) for the
|
|
173
|
-
details and the measurements behind the table above.
|
|
174
|
-
|
|
175
|
-
### Lazy Loading
|
|
176
|
-
|
|
177
|
-
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.
|
|
178
|
-
|
|
179
|
-
```javascript
|
|
180
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
181
|
-
|
|
182
|
-
// Load only the first 1000 rows
|
|
183
|
-
const df = await QvdDataFrame.fromQvd('path/to/file.qvd', {maxRows: 1000});
|
|
184
|
-
console.log(df.shape); // [1000, numberOfColumns]
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
**How it works:**
|
|
188
|
-
|
|
189
|
-
- The library reads only the header, symbol table, and the first N rows from the index table
|
|
190
|
-
- This provides significant memory savings and faster loading times for large files
|
|
191
|
-
|
|
192
|
-
#### Important: Symbol Table and High-Cardinality Fields
|
|
193
|
-
|
|
194
|
-
The QVD format stores data in two parts:
|
|
195
|
-
|
|
196
|
-
1. **Symbol table**: Contains every unique value for every field. It has to be _scanned_ in full, because a
|
|
197
|
-
symbol's length is only known once the previous one has been read — but with `maxRows` the library parses
|
|
198
|
-
only the symbols the requested rows actually reference and steps over the rest. Scanning is cheap; parsing
|
|
199
|
-
is what costs memory.
|
|
200
|
-
2. **Index table**: Contains row-by-row indices into the symbol table (only the requested rows are read)
|
|
201
|
-
|
|
202
|
-
⚠️ **Performance Impact of High-Cardinality Fields:**
|
|
203
|
-
|
|
204
|
-
If your QVD file contains fields with many unique values (high cardinality), such as:
|
|
205
|
-
|
|
206
|
-
- Unique IDs (OrderID, TransactionID, UUID)
|
|
207
|
-
- Timestamps with millisecond precision
|
|
208
|
-
- Unique text fields
|
|
209
|
-
|
|
210
|
-
The symbol table then becomes very large, and has to be scanned end to end even when using `maxRows`. Parsing
|
|
211
|
-
is skipped for symbols the requested rows do not use, so the cost is I/O rather than memory:
|
|
212
|
-
|
|
213
|
-
- **Small symbol table** (fields with reusable values): Fast loading regardless of file size
|
|
214
|
-
- **Large symbol table** (fields with unique values per row): Slower, because the scan is proportional to the
|
|
215
|
-
symbol table's size — but memory stays proportional to the rows you asked for, not to the file
|
|
216
|
-
|
|
217
|
-
You can see what happened on any load through `loadStats` (see [QvdDataFrame](#qvddataframe)):
|
|
218
|
-
|
|
219
|
-
```javascript
|
|
220
|
-
const df = await QvdDataFrame.fromQvd('large.qvd', {maxRows: 1000});
|
|
221
|
-
console.log(df.loadStats);
|
|
222
|
-
// { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
|
|
223
|
-
// symbolFiltering: true, symbolsKept: 3898 }
|
|
224
|
-
//
|
|
225
|
-
// 3,898 symbols parsed for 1,000 rows, out of a file holding 1.7 million.
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
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.
|
|
229
|
-
|
|
230
|
-
**When lazy loading works best:**
|
|
231
|
-
|
|
232
|
-
- Previewing data from very large QVD files without loading the entire file into memory
|
|
233
|
-
- Files where most fields have reusable values (low cardinality)
|
|
234
|
-
- Data exploration and schema inspection of large datasets
|
|
235
|
-
- Faster loading times when you only need a subset of the data
|
|
236
|
-
|
|
237
|
-
### Performance Optimizations
|
|
238
|
-
|
|
239
|
-
The library includes intelligent symbol table parsing that dramatically improves performance when loading partial data with `maxRows`:
|
|
240
|
-
|
|
241
|
-
**Smart Symbol Loading:**
|
|
242
|
-
|
|
243
|
-
When you specify `maxRows`, the library:
|
|
244
|
-
|
|
245
|
-
1. Analyzes which symbols are actually needed for the requested rows
|
|
246
|
-
2. Parses **only** those symbols from the symbol table
|
|
247
|
-
3. Skips parsing unused symbols entirely (not just filtering after parsing)
|
|
248
|
-
|
|
249
|
-
This matters most on files whose symbol table is large because most values are unique — exactly the files where
|
|
250
|
-
a naive `maxRows` would still pay for the whole table. Compared to parsing every symbol regardless of `maxRows`,
|
|
251
|
-
loading a few thousand rows from a multi-million-row file has been measured at roughly an order of magnitude
|
|
252
|
-
faster and an order of magnitude smaller in peak memory.
|
|
253
|
-
|
|
254
|
-
The saving is visible on any file through `loadStats`. On a bundled 1.7-million-row fixture, asking for 1,000
|
|
255
|
-
rows parses 3,898 symbols:
|
|
256
|
-
|
|
257
|
-
```javascript
|
|
258
|
-
const df = await QvdDataFrame.fromQvd('chicago_taxi_rides_2016_01.qvd', {
|
|
259
|
-
maxRows: 1000,
|
|
260
|
-
symbolFilteringThreshold: 0, // this fixture is well under the 50 MB default
|
|
261
|
-
});
|
|
262
|
-
console.log(df.loadStats);
|
|
263
|
-
// { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
|
|
264
|
-
// symbolFiltering: true, symbolsKept: 3898 }
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
The optimisation engages automatically once the symbol table passes `symbolFilteringThreshold`, which defaults
|
|
268
|
-
to 50 MB — the point where the extra analysis pass pays for itself. The example lowers it so the path can be
|
|
269
|
-
demonstrated on a small bundled fixture; on a real high-cardinality file it engages on its own.
|
|
270
|
-
|
|
271
|
-
**Key Benefits:**
|
|
272
|
-
|
|
273
|
-
- Much faster previews of large QVD files
|
|
274
|
-
- Lower memory footprint for data exploration
|
|
275
|
-
- Efficient handling of files with large symbol tables
|
|
276
|
-
- Automatic optimization - no configuration needed
|
|
277
|
-
|
|
278
|
-
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.
|
|
279
|
-
|
|
280
|
-
### QVD File Size Limitations
|
|
281
|
-
|
|
282
|
-
#### The 2 GiB boundary
|
|
283
|
-
|
|
284
|
-
There is one hard limit worth knowing before anything else: **a full load cannot read a file larger than
|
|
285
|
-
2 GiB**, because Node caps `fs.readFile` there. It fails with a raw Node error rather than a `QvdError`:
|
|
286
|
-
|
|
287
|
-
```
|
|
288
|
-
RangeError: File size (2362232013) is greater than 2 GiB
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
**`maxRows` reads past that boundary**, because the lazy path reads the file in chunks instead:
|
|
292
|
-
|
|
293
|
-
```javascript
|
|
294
|
-
// A 2.2 GiB file
|
|
295
|
-
await QvdDataFrame.fromQvd(huge, {}); // ❌ RangeError, above
|
|
296
|
-
await QvdDataFrame.fromQvd(huge, {maxRows: 1000}); // ✅ works
|
|
297
76
|
```
|
|
298
77
|
|
|
299
|
-
|
|
300
|
-
|
|
78
|
+
Reaching for `fromQvd` when you wanted one of the other three is the common mistake, and
|
|
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.
|
|
301
81
|
|
|
302
|
-
|
|
82
|
+
→ [Choosing an entry point](https://qvdjs.ptarmiganlabs.com/v0.10/getting-started/)
|
|
303
83
|
|
|
304
|
-
|
|
84
|
+
## What a cell holds
|
|
305
85
|
|
|
306
|
-
|
|
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` |
|
|
307
92
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
- **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.
|
|
312
|
-
- **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.
|
|
313
|
-
|
|
314
|
-
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.
|
|
315
|
-
|
|
316
|
-
The most common question at this point is usually:
|
|
317
|
-
|
|
318
|
-
> "How large of a QVD file can I work with using qvdjs?"
|
|
319
|
-
|
|
320
|
-
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.
|
|
321
|
-
|
|
322
|
-
**Technical Details:**
|
|
323
|
-
|
|
324
|
-
The maximum QVD file size you can handle with qvdjs depends on several factors and there is no single fixed limit:
|
|
325
|
-
|
|
326
|
-
- **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.
|
|
327
|
-
|
|
328
|
-
You can also adjust the `memorySafetyFactor` option (default 0.8 = 80% of the budget) to trade
|
|
329
|
-
headroom for reach:
|
|
330
|
-
|
|
331
|
-
```javascript
|
|
332
|
-
const df = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
333
|
-
memorySafetyFactor: 0.9, // Use 90% of the budget instead of the default 80%
|
|
334
|
-
});
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
**What the budget is measured against.** The V8 heap ceiling, and any container memory limit reported by
|
|
338
|
-
`process.constrainedMemory()`. Those are the two limits that actually terminate a process — exceeding the heap
|
|
339
|
-
is a fatal, uncatchable V8 error, and exceeding a cgroup limit is a SIGKILL that arrives as exit 137 with no
|
|
340
|
-
JavaScript error at all.
|
|
341
|
-
|
|
342
|
-
What the operating system reports as _free_ memory is deliberately **not** part of it. It is recorded in the
|
|
343
|
-
error context for diagnostics and ignored for the decision, because it does not describe a wall the process
|
|
344
|
-
can hit — a machine with virtual memory gets slower, not fatal — and because the figure is not dependable:
|
|
345
|
-
on macOS it counts only free and speculative pages, excluding the file-cache pages the OS reclaims on demand,
|
|
346
|
-
and readings on one idle machine varied nine-fold within minutes. Budgeting from it meant the same file loaded
|
|
347
|
-
or was refused depending on when you asked.
|
|
348
|
-
|
|
349
|
-
**Turning the check off.** `memorySafetyFactor: 0` disables it entirely, for callers who would rather manage
|
|
350
|
-
memory themselves, or run on a runtime whose limits cannot be measured — Bun reports its current heap as its
|
|
351
|
-
heap limit, so the ceiling it advertises is meaningless.
|
|
352
|
-
|
|
353
|
-
```javascript
|
|
354
|
-
const df = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
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.
|
|
358
|
-
|
|
359
|
-
- **Data Characteristics**: The actual memory consumption depends _heavily_ on what's inside your QVD:
|
|
360
|
-
- **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.
|
|
361
|
-
- **Number of Rows**: More rows mean larger index tables in memory
|
|
362
|
-
- **Number of Fields**: More columns increase overall memory requirements
|
|
363
|
-
- **Data Types**: String data generally uses more memory than numeric data
|
|
364
|
-
|
|
365
|
-
- **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.
|
|
366
|
-
|
|
367
|
-
- **Practical Guidance**:
|
|
368
|
-
- **Count cells, not megabytes.** What exhausts the heap on a full load is one array per row plus
|
|
369
|
-
one slot per cell, so a file's size on disk predicts very little. On a default 4 GB heap the
|
|
370
|
-
guard currently accepts roughly:
|
|
371
|
-
|
|
372
|
-
| Columns | Rows | Cells |
|
|
373
|
-
| ------- | ------ | ----- |
|
|
374
|
-
| 5 | 30.5 M | 153 M |
|
|
375
|
-
| 10 | 22.5 M | 225 M |
|
|
376
|
-
| 20 | 14.7 M | 295 M |
|
|
377
|
-
| 40 | 8.7 M | 349 M |
|
|
378
|
-
|
|
379
|
-
These are about 4× what they were before 2026-09-11, for two reasons: the bitwise decode
|
|
380
|
-
(#134) cut what a read actually needs by roughly 2.4×, and the guard's model was recalibrated
|
|
381
|
-
against that — it had been over-estimating a real read by 4.8×. The guard still errs high,
|
|
382
|
-
admitting a load only at 1.3–2× the heap it measurably needs, because under-estimating means
|
|
383
|
-
an uncatchable abort while over-estimating means a catchable refusal. Doubling the heap with
|
|
384
|
-
`--max-old-space-size` roughly doubles these numbers.
|
|
385
|
-
|
|
386
|
-
- **A columnar read is not bounded by any of this.** `QvdColumnTable.fromQvd()` stores codes in
|
|
387
|
-
typed arrays, which live outside the V8 heap, so the heap ceiling barely applies: the 38 MB
|
|
388
|
-
taxi fixture reads columnar in a 15 MB heap, where the same file as rows needs 367 MB. If you
|
|
389
|
-
are hitting the table above, reading the columns you need is usually the answer rather than a
|
|
390
|
-
bigger heap.
|
|
391
|
-
|
|
392
|
-
- High-cardinality data (unique values in most rows) also loads the symbol table, so the ceiling
|
|
393
|
-
drops further — subtract about 90 MB of heap per million distinct values
|
|
394
|
-
- **With increased heap** (e.g., 16GB+), you can handle proportionally larger files by adjusting `memorySafetyFactor`
|
|
395
|
-
- Use lazy loading (`maxRows` option) when possible to reduce memory footprint when reading
|
|
396
|
-
- Monitor memory usage with tools like `process.memoryUsage()` for your specific use cases
|
|
397
|
-
- 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.
|
|
398
|
-
|
|
399
|
-
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.
|
|
400
|
-
|
|
401
|
-
#### Why Safety Limits Exist
|
|
402
|
-
|
|
403
|
-
The library implements **dynamic safety limits** to prevent catastrophic crashes. Without these limits:
|
|
404
|
-
|
|
405
|
-
- **Your application will crash hard** - Node.js terminates with `FATAL ERROR: Reached heap limit` when attempting to load files that are too large
|
|
406
|
-
- **No error handling is possible** - JavaScript try-catch blocks cannot intercept out-of-memory (OOM) crashes at the V8 engine level
|
|
407
|
-
- **The entire process dies** - Not just the QVD operation, but your entire application terminates ungracefully
|
|
408
|
-
|
|
409
|
-
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.
|
|
410
|
-
|
|
411
|
-
**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.
|
|
412
96
|
|
|
413
97
|
```javascript
|
|
414
|
-
|
|
415
|
-
const df = await QvdDataFrame.fromQvd('huge-file.qvd'); // 💥 FATAL ERROR
|
|
416
|
-
// Your application is now terminated
|
|
417
|
-
```
|
|
98
|
+
import {QvdDataFrame, qlikSerialToDate} from 'qvdjs';
|
|
418
99
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const df = await QvdDataFrame.fromQvd('huge-file.qvd');
|
|
424
|
-
} catch (error) {
|
|
425
|
-
if (error.name === 'QvdValidationError') {
|
|
426
|
-
console.log('File too large, trying with maxRows:', error.context.recommendedMaxRows);
|
|
427
|
-
// ✅ Your application continues running
|
|
428
|
-
}
|
|
429
|
-
}
|
|
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'
|
|
430
104
|
```
|
|
431
105
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
### Writing QVD files
|
|
435
|
-
|
|
436
|
-
`toQvd()` builds the symbol and index tables in memory and writes the file in one pass. Two things are worth
|
|
437
|
-
knowing about it.
|
|
438
|
-
|
|
439
|
-
**Row count is no longer a barrier.** Writing used to fail outright above roughly 122,000 rows with a
|
|
440
|
-
`RangeError: Maximum call stack size exceeded`, because the bit width for each column was derived with
|
|
441
|
-
`Math.max(...oneArgumentPerRow)`. It is now derived from the symbol count in constant time, and writes of
|
|
442
|
-
hundreds of thousands of rows are routine. The regression suite round-trips a 200,000-row data frame and a
|
|
443
|
-
150,000-row slice of real Qlik output on every CI run, across Linux, Windows and macOS.
|
|
444
|
-
|
|
445
|
-
**Memory scales with the data, not with the file you are writing.** A write holds the whole data frame plus its
|
|
446
|
-
symbol and index tables, so it needs more memory than reading the equivalent file. There is no lazy equivalent
|
|
447
|
-
of `maxRows` for writing — if the data does not fit in the heap, split it across several QVDs.
|
|
448
|
-
|
|
449
|
-
Writes are not atomic; see [Known limitations](#known-limitations).
|
|
450
|
-
|
|
451
|
-
#### Progress tracking
|
|
452
|
-
|
|
453
|
-
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
|
|
454
107
|
|
|
455
108
|
```javascript
|
|
456
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
457
|
-
|
|
458
109
|
const df = await QvdDataFrame.fromDict({
|
|
459
|
-
columns: ['
|
|
460
|
-
data:
|
|
110
|
+
columns: ['Region', 'Year', 'Amount'],
|
|
111
|
+
data: [
|
|
112
|
+
['North', 2025, 1200.0],
|
|
113
|
+
['South', 2025, 980.5],
|
|
114
|
+
],
|
|
461
115
|
});
|
|
462
116
|
|
|
463
|
-
await df.toQvd('
|
|
464
|
-
onProgress: (
|
|
465
|
-
console.log(`${progress.stage}: ${progress.percent}% complete`);
|
|
466
|
-
},
|
|
117
|
+
await df.toQvd('out.qvd', {
|
|
118
|
+
onProgress: ({stage, percent}) => console.log(`${stage}: ${percent}%`),
|
|
467
119
|
});
|
|
468
120
|
```
|
|
469
121
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
- `current` (number): Current progress value (e.g., rows processed, columns completed)
|
|
476
|
-
- `total` (number): Total progress value for the current stage
|
|
477
|
-
- `percent` (number): Progress percentage (0-100) calculated as `(current / total) * 100`
|
|
478
|
-
|
|
479
|
-
**Progress stages:**
|
|
480
|
-
|
|
481
|
-
- `symbol-table`: Building unique value tables for each column
|
|
482
|
-
- `index-table`: Processing all data rows and creating index mappings
|
|
483
|
-
- `header`: Generating XML header metadata
|
|
484
|
-
- `write`: Writing data to disk
|
|
485
|
-
|
|
486
|
-
**Performance optimizations:**
|
|
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`.
|
|
487
127
|
|
|
488
|
-
|
|
128
|
+
→ [Writing a QVD](https://qvdjs.ptarmiganlabs.com/v0.10/guides/)
|
|
489
129
|
|
|
490
|
-
|
|
491
|
-
- **Map-based lookups**: O(1) symbol index lookups instead of O(n) findIndex operations
|
|
492
|
-
- **Reduced algorithmic complexity**: From O(n×m×s) to O(n×m) where n=rows, m=columns, s=symbols
|
|
130
|
+
## Reading part of a file
|
|
493
131
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
To inspect a file's schema without reading its data, use `readMetadata`. It stops at the XML
|
|
499
|
-
header, so it costs the same for a 40 MB file as for a 40 GB one:
|
|
500
|
-
|
|
501
|
-
```javascript
|
|
502
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
503
|
-
|
|
504
|
-
const {columns, rowCount, fields} = await QvdDataFrame.readMetadata('path/to/file.qvd');
|
|
505
|
-
|
|
506
|
-
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
Note that `fromQvd(path, {maxRows: 0})` is not equivalent — it loads no rows, but still reads and
|
|
510
|
-
parses the whole symbol table.
|
|
511
|
-
|
|
512
|
-
For metadata alongside the data, every accessor below is available on a loaded frame:
|
|
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.
|
|
513
135
|
|
|
514
136
|
```javascript
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
// Access file-level metadata
|
|
521
|
-
console.log(df.fileMetadata.tableName);
|
|
522
|
-
console.log(df.fileMetadata.createUtcTime);
|
|
523
|
-
console.log(df.fileMetadata.noOfRecords);
|
|
524
|
-
|
|
525
|
-
// Access field-level metadata
|
|
526
|
-
const fieldMeta = df.getFieldMetadata('ProductKey');
|
|
527
|
-
console.log(fieldMeta.comment);
|
|
528
|
-
console.log(fieldMeta.numberFormat);
|
|
529
|
-
console.log(fieldMeta.tags);
|
|
530
|
-
|
|
531
|
-
// Get all field metadata
|
|
532
|
-
const allFields = df.getAllFieldMetadata();
|
|
533
|
-
allFields.forEach((field) => {
|
|
534
|
-
console.log(`${field.fieldName}: ${field.noOfSymbols} symbols`);
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
// Modify metadata (only modifiable properties can be changed)
|
|
538
|
-
df.setFileMetadata({
|
|
539
|
-
tableName: 'UpdatedProducts',
|
|
540
|
-
comment: 'Modified product data',
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
df.setFieldMetadata('ProductKey', {
|
|
544
|
-
comment: 'Primary key for products',
|
|
545
|
-
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'],
|
|
546
142
|
});
|
|
547
143
|
|
|
548
|
-
|
|
549
|
-
|
|
144
|
+
console.log(df.loadStats);
|
|
145
|
+
// { symbolTableBytes, totalRows, rowsLoaded, offset, symbolFiltering, symbolsKept }
|
|
550
146
|
```
|
|
551
147
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
The library includes built-in protection against path traversal attacks.
|
|
555
|
-
|
|
556
|
-
**Default Security Behavior:**
|
|
557
|
-
|
|
558
|
-
By default, all file operations are restricted to the **current working directory (CWD)** and its subdirectories. This means:
|
|
559
|
-
|
|
560
|
-
- ✅ Files within CWD can be accessed: `./data/file.qvd` or `data/file.qvd`
|
|
561
|
-
- ❌ Files outside CWD are blocked: `/etc/passwd` or `../../../sensitive.qvd`
|
|
562
|
-
- ❌ Path traversal attempts are detected and blocked
|
|
563
|
-
|
|
564
|
-
This default behavior protects against path traversal attacks without requiring additional configuration.
|
|
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.
|
|
565
150
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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:
|
|
569
154
|
|
|
570
155
|
```javascript
|
|
571
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
572
|
-
|
|
573
|
-
// Restrict reading to a specific directory
|
|
574
|
-
const allowedDataDir = '/var/data/qvd-files';
|
|
575
|
-
const df = await QvdDataFrame.fromQvd('reports/sales.qvd', {
|
|
576
|
-
allowedDir: allowedDataDir,
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
// Restrict writing to a specific directory
|
|
580
|
-
const allowedOutputDir = '/var/output';
|
|
581
|
-
await df.toQvd('processed/sales-filtered.qvd', {
|
|
582
|
-
allowedDir: allowedOutputDir,
|
|
583
|
-
});
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
**Security Features:**
|
|
587
|
-
|
|
588
|
-
- **Path Normalization**: All paths are automatically normalized using `path.resolve()` to eliminate `..` and `.` segments
|
|
589
|
-
- **Null Byte Protection**: Detects and blocks null byte injection attempts
|
|
590
|
-
- **Default CWD Restriction**: By default, file operations are restricted to the current working directory (CWD) and its subdirectories to prevent path traversal attacks
|
|
591
|
-
- **Custom Directory Restriction**: Optional `allowedDir` parameter allows you to specify a different base directory
|
|
592
|
-
- **Symlinks are resolved**: Containment is decided by the filesystem, not by comparing strings. Both paths are
|
|
593
|
-
resolved through symlinks and compared by device and inode, so a link _inside_ `allowedDir` that points
|
|
594
|
-
outside it is refused — for writes as well as reads. A string comparison sees only the link's own name, still
|
|
595
|
-
under `allowedDir`, and lets it through; that meant a link planted in an upload directory could be used to
|
|
596
|
-
read any file the process could read, and to overwrite and truncate any file it could write.
|
|
597
|
-
- **Case is handled as the filesystem handles it**: comparing by inode means `Qvd` and `qvd` are the same
|
|
598
|
-
directory on a case-insensitive volume and different directories on a case-sensitive one, without guessing
|
|
599
|
-
from `process.platform`. macOS supports both.
|
|
600
|
-
- **Security Errors**: Throws `QvdSecurityError` with detailed context when security violations are detected.
|
|
601
|
-
The context includes a `check` field saying whether the filesystem or the fallback string comparison refused,
|
|
602
|
-
so a rejection of a path that looks contained is traceable to a symlink or a case difference.
|
|
603
|
-
|
|
604
|
-
**A note on `allowedDir` values:** `null`, `undefined` and `''` all fall back to the current working directory
|
|
605
|
-
rather than meaning "no restriction" — callers routinely produce those from optional config or a JSON round
|
|
606
|
-
trip, and silently dropping the sandbox there would be a security hole. To permit an entire volume, pass its
|
|
607
|
-
root explicitly (`'/'` on POSIX, `'C:\\'` on Windows).
|
|
608
|
-
|
|
609
|
-
**Not covered:** this is a check on a path, so it remains open to a symlink swapped in between the check and the
|
|
610
|
-
open. Closing that would require opening with `O_NOFOLLOW` and verifying the descriptor.
|
|
611
|
-
|
|
612
|
-
**Best Practices:**
|
|
613
|
-
|
|
614
|
-
1. **Understand default security**: Files are restricted to CWD by default - no additional configuration needed for basic protection
|
|
615
|
-
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
|
|
616
|
-
3. **Validate user input**: Even with built-in protections, validate and sanitize any user-provided paths
|
|
617
|
-
4. **Principle of least privilege**: Use the most restrictive `allowedDir` possible for your use case
|
|
618
|
-
5. **Monitor security errors**: Log and monitor `QvdSecurityError` exceptions as they may indicate attack attempts
|
|
619
|
-
|
|
620
|
-
**Examples:**
|
|
621
|
-
|
|
622
|
-
```javascript
|
|
623
|
-
import {QvdDataFrame, QvdSecurityError} from 'qvdjs';
|
|
624
|
-
|
|
625
|
-
// Example 1: Default behavior (restricted to CWD)
|
|
626
|
-
// This is safe by default - no path traversal possible
|
|
627
|
-
try {
|
|
628
|
-
const df = await QvdDataFrame.fromQvd('data/file.qvd'); // ✅ Works (within CWD)
|
|
629
|
-
const df2 = await QvdDataFrame.fromQvd('../../../etc/passwd'); // ❌ Throws QvdSecurityError
|
|
630
|
-
} catch (error) {
|
|
631
|
-
if (error instanceof QvdSecurityError) {
|
|
632
|
-
console.error('Path traversal blocked:', error.message);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Example 2: Custom allowedDir for specific use cases
|
|
637
156
|
try {
|
|
638
|
-
const df = await QvdDataFrame.fromQvd(
|
|
639
|
-
allowedDir: '/safe/directory',
|
|
640
|
-
});
|
|
641
|
-
// Process data...
|
|
157
|
+
const df = await QvdDataFrame.fromQvd('huge.qvd');
|
|
642
158
|
} catch (error) {
|
|
643
|
-
if (error
|
|
644
|
-
|
|
645
|
-
console.error('Context:', error.context);
|
|
646
|
-
// Log security incident
|
|
647
|
-
} else {
|
|
648
|
-
throw error;
|
|
159
|
+
if (error.name === 'QvdValidationError') {
|
|
160
|
+
await QvdDataFrame.fromQvd('huge.qvd', {maxRows: error.context.recommendedMaxRows});
|
|
649
161
|
}
|
|
650
162
|
}
|
|
651
163
|
```
|
|
652
164
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
Honest boundaries rather than an issue list — these are the ones that change what you can do.
|
|
656
|
-
|
|
657
|
-
| Limitation | What it means in practice | Tracked as |
|
|
658
|
-
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
659
|
-
| **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) |
|
|
660
|
-
| **A full load materialises every row in memory** | Rows are stored as one JavaScript array per row with a boxed value per cell, measured at roughly 11 bytes per cell. That, not file size, sets the ceiling in the table above. `maxRows` reads a prefix, and `QvdColumnTable` avoids row materialisation entirely — but there is still no streaming or chunked read of an arbitrary window. | [#140](https://github.com/ptarmiganlabs/qvdjs/issues/140) |
|
|
661
|
-
| **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) |
|
|
662
|
-
| **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) |
|
|
663
|
-
| **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) |
|
|
664
|
-
|
|
665
|
-
Numeric-looking strings are currently coerced to numbers on read, which is being reconsidered as a breaking
|
|
666
|
-
change in [#120](https://github.com/ptarmiganlabs/qvdjs/issues/120). Empty and whitespace-only strings are _not_
|
|
667
|
-
coerced — that was a defect, and is fixed.
|
|
668
|
-
|
|
669
|
-
## QVD File Format
|
|
670
|
-
|
|
671
|
-
The QVD file format is a binary file format that is used by QlikView to store data. The format is proprietary. However,
|
|
672
|
-
the format is well documented and can be parsed without the need of a QlikView installation. In fact, a QVD file consists
|
|
673
|
-
of three parts: a XML header, and two binary parts, the symbol and the index table. The XML header contains meta information
|
|
674
|
-
about the QVD file, such as the number of data records and the names of the fields. The symbol table contains the actual
|
|
675
|
-
distinct values of the fields. The index table contains the actual data records. The index table is a list of indices
|
|
676
|
-
which point to values in the symbol table.
|
|
677
|
-
|
|
678
|
-
### XML Header
|
|
679
|
-
|
|
680
|
-
The XML header contains meta information about the QVD file. The header is always located at the beginning of the file and
|
|
681
|
-
is in human readable text format. The header contains information about the number of data records, the names of the fields,
|
|
682
|
-
and the data types of the fields.
|
|
683
|
-
|
|
684
|
-
### Symbol Table
|
|
685
|
-
|
|
686
|
-
The symbol table contains the distinct/unique values of the fields and is located directly after the XML header. The order
|
|
687
|
-
of columns in the symbol table corresponds to the order of the fields in the XML header. The length and offset of the
|
|
688
|
-
symbol sections of each column are also stored in the XML header.
|
|
689
|
-
|
|
690
|
-
**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).
|
|
691
|
-
|
|
692
|
-
Each symbol section consists of the unique symbols of the
|
|
693
|
-
respective column. The type of a single symbol is determined by a type byte prefixed to the respective symbol value. The
|
|
694
|
-
following type of symbols are supported:
|
|
695
|
-
|
|
696
|
-
| Code | Type | Description |
|
|
697
|
-
| ---- | ------------ | --------------------------------------------------------------------------------------------- |
|
|
698
|
-
| 1 | Integer | signed 4-byte integer (little endian) |
|
|
699
|
-
| 2 | Float | signed 8-byte IEEE floating point number (little endian) |
|
|
700
|
-
| 4 | String | null terminated string |
|
|
701
|
-
| 5 | Dual Integer | signed 4-byte integer (little endian) followed by a null terminated string |
|
|
702
|
-
| 6 | Dual Float | signed 8-byte IEEE floating point number (little endian) followed by a null terminated string |
|
|
703
|
-
|
|
704
|
-
### Index Table
|
|
705
|
-
|
|
706
|
-
After the symbol table, the index table follows. The index table contains the actual data records. The index table contains
|
|
707
|
-
binary indices that refrences to the values of each row in the symbol table. The order of the columns in the index table
|
|
708
|
-
corresponds to the order of the fields in the XML header. Hence, the index table does not contain the actual values of a
|
|
709
|
-
data record, but only the indices that point to the values in the symbol table.
|
|
710
|
-
|
|
711
|
-
### Empty QVD Files
|
|
712
|
-
|
|
713
|
-
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.
|
|
714
|
-
|
|
715
|
-
**Characteristics of Empty QVD Files:**
|
|
716
|
-
|
|
717
|
-
- **NoOfRecords**: Set to `0` in the XML header
|
|
718
|
-
- **RecordByteSize**: Set to `1` (following Qlik Sense's convention)
|
|
719
|
-
- **Fields**: Field definitions are present and complete with metadata
|
|
720
|
-
- **Symbol Table**: Each field has zero symbols (`NoOfSymbols=0`, `Offset=0`, `Length=0`)
|
|
721
|
-
- **Index Table**: Empty with `Length=0`
|
|
722
|
-
- **BitWidth**: Can vary per field (typically `0` or `8`)
|
|
723
|
-
|
|
724
|
-
**Example Empty QVD XML Header:**
|
|
725
|
-
|
|
726
|
-
```xml
|
|
727
|
-
<QvdTableHeader>
|
|
728
|
-
<TableName>EmptyTable</TableName>
|
|
729
|
-
<Fields>
|
|
730
|
-
<QvdFieldHeader>
|
|
731
|
-
<FieldName>Country</FieldName>
|
|
732
|
-
<BitOffset>0</BitOffset>
|
|
733
|
-
<BitWidth>0</BitWidth>
|
|
734
|
-
<Bias>0</Bias>
|
|
735
|
-
<NoOfSymbols>0</NoOfSymbols>
|
|
736
|
-
<Offset>0</Offset>
|
|
737
|
-
<Length>0</Length>
|
|
738
|
-
</QvdFieldHeader>
|
|
739
|
-
</Fields>
|
|
740
|
-
<RecordByteSize>1</RecordByteSize>
|
|
741
|
-
<NoOfRecords>0</NoOfRecords>
|
|
742
|
-
<Offset>0</Offset>
|
|
743
|
-
<Length>0</Length>
|
|
744
|
-
</QvdTableHeader>
|
|
745
|
-
```
|
|
746
|
-
|
|
747
|
-
**Working with Empty QVDs:**
|
|
748
|
-
|
|
749
|
-
```javascript
|
|
750
|
-
import {QvdDataFrame} from 'qvdjs';
|
|
751
|
-
|
|
752
|
-
// Create an empty QVD with field structure but no data
|
|
753
|
-
const emptyDf = new QvdDataFrame(
|
|
754
|
-
[], // No data rows
|
|
755
|
-
['Country', 'Year', 'Sales'], // Column definitions
|
|
756
|
-
);
|
|
757
|
-
|
|
758
|
-
// Set metadata
|
|
759
|
-
emptyDf.setFileMetadata({
|
|
760
|
-
tableName: 'EmptyTable',
|
|
761
|
-
comment: 'Template table structure',
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
// Write empty QVD (compatible with Qlik Sense)
|
|
765
|
-
await emptyDf.toQvd('empty.qvd');
|
|
766
|
-
|
|
767
|
-
// Read it back
|
|
768
|
-
const loadedDf = await QvdDataFrame.fromQvd('empty.qvd');
|
|
769
|
-
console.log(loadedDf.shape); // [0, 3] - zero rows, three columns
|
|
770
|
-
console.log(loadedDf.columns); // ['Country', 'Year', 'Sales']
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
Empty QVDs are fully supported for both reading and writing, maintaining compatibility with files created by Qlik Sense and QlikView.
|
|
774
|
-
|
|
775
|
-
## API Documentation
|
|
776
|
-
|
|
777
|
-
### QvdDataFrame
|
|
778
|
-
|
|
779
|
-
The `QvdDataFrame` class represents the data frame stored inside of a finally parsed QVD file. It provides a high-level
|
|
780
|
-
abstraction access to the QVD file content. This includes meta information as well as access to the actual data records.
|
|
781
|
-
|
|
782
|
-
| Property | Type | Description |
|
|
783
|
-
| -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
784
|
-
| `shape` | `number[]` | The shape of the data table. The first element is the number of rows, the second element is the number of columns. |
|
|
785
|
-
| `data` | `any[][]` | The actual data records of the QVD file. The first dimension represents the single rows. |
|
|
786
|
-
| `columns` | `string[]` | The names of the fields that are contained in the QVD file. |
|
|
787
|
-
| `metadata` | `object` | The complete metadata object from the QVD file header, or null if not loaded from a QVD file. |
|
|
788
|
-
| `fileMetadata` | `object` | File-level metadata from the QVD header (qvBuildNo, tableName, createUtcTime, etc.). |
|
|
789
|
-
| `loadStats` | `object` | What the read did: `symbolTableBytes`, `totalRows`, `rowsLoaded`, `symbolFiltering`, `symbolsKept`. `null` unless the frame came from `fromQvd()`. |
|
|
790
|
-
|
|
791
|
-
#### `static fromQvd(path: string, options?: object): Promise<QvdDataFrame>`
|
|
792
|
-
|
|
793
|
-
The static method `QvdDataFrame.fromQvd` loads a QVD file from the given path and parses it. The method returns a promise that resolves
|
|
794
|
-
to a `QvdDataFrame` instance.
|
|
795
|
-
|
|
796
|
-
**Parameters:**
|
|
797
|
-
|
|
798
|
-
- `path` (string): The path to the QVD file.
|
|
799
|
-
- `options` (object, optional): Loading options
|
|
800
|
-
- `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.
|
|
801
|
-
- `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.
|
|
802
|
-
- `memorySafetyFactor` (number, optional): Fraction (0.0-1.0) of the memory budget a load may use. Default is 0.8 (80%). The budget is the smaller of the V8 heap limit and any container memory limit; see [QVD File Size Limitations](#qvd-file-size-limitations). Increase this (e.g., to 0.5 or 0.7) when running with a larger heap via `--max-old-space-size`. **`0` disables the memory check entirely.**
|
|
803
|
-
- `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.
|
|
804
|
-
|
|
805
|
-
**Example:**
|
|
806
|
-
|
|
807
|
-
```javascript
|
|
808
|
-
// Load all rows (default behavior)
|
|
809
|
-
const df = await QvdDataFrame.fromQvd('path/to/file.qvd');
|
|
810
|
-
|
|
811
|
-
// Load only the first 1000 rows
|
|
812
|
-
const dfLazy = await QvdDataFrame.fromQvd('path/to/file.qvd', {maxRows: 1000});
|
|
813
|
-
|
|
814
|
-
// Load with security restriction (recommended for production)
|
|
815
|
-
const dfSecure = await QvdDataFrame.fromQvd('reports/sales.qvd', {
|
|
816
|
-
allowedDir: '/var/data/qvd-files',
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
// Load with increased memory usage for large heap configurations
|
|
820
|
-
const dfLarge = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
821
|
-
memorySafetyFactor: 0.9, // Use 90% of the budget instead of the default 80%
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
// Manage memory yourself: skip the check entirely
|
|
825
|
-
const dfUnchecked = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
|
|
826
|
-
|
|
827
|
-
// Inspect what the read actually did
|
|
828
|
-
const preview = await QvdDataFrame.fromQvd('large-file.qvd', {maxRows: 500});
|
|
829
|
-
console.log(preview.loadStats.symbolFiltering); // true when the two-pass path ran
|
|
830
|
-
console.log(preview.loadStats.symbolsKept); // how many symbols it kept
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
#### `static readMetadata(path: string, options?: object): Promise<object>`
|
|
834
|
-
|
|
835
|
-
Reads a QVD file's schema and header metadata without reading its data. The cost is the same
|
|
836
|
-
whatever the file's size, because it stops at the XML header and never touches the symbol or
|
|
837
|
-
index tables.
|
|
838
|
-
|
|
839
|
-
This is not the same as `fromQvd(path, {maxRows: 0})`. That loads no rows but still reads and
|
|
840
|
-
parses the entire symbol table — 0.4 MB on a 38 MB file, but 15 MB on a high-cardinality one, and
|
|
841
|
-
it grows with the data. Measured on a 200,000-row file where every value is distinct,
|
|
842
|
-
`readMetadata` is **352× faster**, and unlike `{maxRows: 0}` it does not get slower as the file
|
|
843
|
-
grows.
|
|
844
|
-
|
|
845
|
-
**Parameters:**
|
|
846
|
-
|
|
847
|
-
- `path` (string): The path to the QVD file.
|
|
848
|
-
- `options` (object, optional):
|
|
849
|
-
- `allowedDir` (string, optional): Base directory for file access validation, applied exactly as
|
|
850
|
-
it is for `fromQvd`.
|
|
851
|
-
|
|
852
|
-
**Returns** a plain object — deliberately not a `QvdDataFrame`, since one with `data: []` would be
|
|
853
|
-
indistinguishable from an empty file at the call site:
|
|
854
|
-
|
|
855
|
-
| Property | Type | Description |
|
|
856
|
-
| -------------- | ---------- | ----------------------------------------------------------------------------------- |
|
|
857
|
-
| `columns` | `string[]` | Field names, in file order. |
|
|
858
|
-
| `rowCount` | `number` | Rows the **file** declares. Nothing was loaded; this is not a count of rows read. |
|
|
859
|
-
| `columnCount` | `number` | Number of fields. |
|
|
860
|
-
| `fields` | `object[]` | Per-field metadata, same shape and order as `getFieldMetadata()` on a loaded frame. |
|
|
861
|
-
| `fileMetadata` | `object` | Same shape as the `fileMetadata` accessor on a loaded frame. |
|
|
862
|
-
| `metadata` | `object` | The raw `QvdTableHeader`, as `metadata` gives it. |
|
|
863
|
-
|
|
864
|
-
**Example:**
|
|
865
|
-
|
|
866
|
-
```javascript
|
|
867
|
-
// What is in this file, without reading any of it
|
|
868
|
-
const {columns, rowCount, fields} = await QvdDataFrame.readMetadata('sales.qvd');
|
|
869
|
-
|
|
870
|
-
console.log(`${rowCount} rows x ${columns.length} columns`);
|
|
871
|
-
|
|
872
|
-
for (const field of fields) {
|
|
873
|
-
console.log(`${field.fieldName}: ${field.noOfSymbols} distinct values`);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Decide whether it is worth loading at all
|
|
877
|
-
if (rowCount < 1_000_000) {
|
|
878
|
-
const df = await QvdDataFrame.fromQvd('sales.qvd');
|
|
879
|
-
}
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
### QvdColumnTable
|
|
165
|
+
When it is `iterate()` that overflowed, the context carries `recommendedChunkSize` as well, so a
|
|
166
|
+
caller cannot mistake one recommendation for the other.
|
|
883
167
|
|
|
884
|
-
|
|
885
|
-
same symbol resolution as `QvdDataFrame.fromQvd` — it simply stops before building rows, and
|
|
886
|
-
keeps what the decoder already produced: one `Int32Array` of codes per field, and one resolved
|
|
887
|
-
value per _distinct_ symbol.
|
|
168
|
+
→ [Memory and file size limits](https://qvdjs.ptarmiganlabs.com/v0.10/overview/memory-and-file-size-limits/)
|
|
888
169
|
|
|
889
|
-
|
|
890
|
-
representation is alive:
|
|
170
|
+
## Metadata
|
|
891
171
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
| `QvdColumnTable.fromQvd` | **141 MiB** | **3.6 ms** |
|
|
896
|
-
|
|
897
|
-
Use it when you want to scan or aggregate a few columns of a large file. Use `QvdDataFrame` when
|
|
898
|
-
you want rows. It does not convert to a data frame, deliberately — holding both representations
|
|
899
|
-
is the one configuration in which this costs more than it saves, and re-reading a file as rows
|
|
900
|
-
costs no more than reading it as rows always did.
|
|
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.
|
|
901
175
|
|
|
902
176
|
```javascript
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
const fare = table.column('fare');
|
|
177
|
+
const df = await QvdDataFrame.fromQvd('products.qvd');
|
|
178
|
+
df.fileMetadata.tableName; // 'Products'
|
|
179
|
+
df.getFieldMetadata('ProductKey'); // { comment, numberFormat, tags, ... }
|
|
907
180
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const values = fare.numericSymbols();
|
|
911
|
-
let total = 0;
|
|
912
|
-
|
|
913
|
-
for (let row = 0; row < codes.length; row++) {
|
|
914
|
-
const code = codes[row];
|
|
915
|
-
if (code >= 0) {
|
|
916
|
-
const value = values[code];
|
|
917
|
-
if (!Number.isNaN(value)) total += value;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// Or, more simply
|
|
922
|
-
for (const value of fare) {
|
|
923
|
-
/* number | string | null */
|
|
924
|
-
}
|
|
925
|
-
console.log(fare.at(0), fare.toArray().length);
|
|
181
|
+
df.setFileMetadata({tableName: 'UpdatedProducts'});
|
|
182
|
+
await df.toQvd('products-v2.qvd');
|
|
926
183
|
```
|
|
927
184
|
|
|
928
|
-
|
|
929
|
-
`column(name)`, `columns`, `rowCount`, `shape`, `metadata`, `loadStats`.
|
|
930
|
-
|
|
931
|
-
**`QvdColumn`** — what `column(name)` returns, frozen:
|
|
932
|
-
|
|
933
|
-
| Member | Type | Description |
|
|
934
|
-
| -------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
935
|
-
| `name` | `string` | The field name. |
|
|
936
|
-
| `length` | `number` | Rows in the column. |
|
|
937
|
-
| `codes` | `Int32Array` | One stored index per row. **Negative means NULL.** The table's own array — treat as read-only. |
|
|
938
|
-
| `symbols` | `ReadonlyArray<any>` | The distinct values, indexed by the codes. One entry per distinct value, not per row. |
|
|
939
|
-
| `at(row)` | `any` | The value of one row, or `null`. |
|
|
940
|
-
| `[Symbol.iterator]` | | Iterates values without materialising the column. |
|
|
941
|
-
| `toArray()` | `Array<any>` | Lossless; cell-for-cell what `data[row][column]` holds. Caller-owned. |
|
|
942
|
-
| `numericSymbols()` | `Float64Array` | The **dictionary** as numbers, NaN for non-numeric. One entry per distinct value — 17 KB for a 1.7 M-row column. |
|
|
943
|
-
| `toFloat64Array(options?)` | `Float64Array` | One number **per row**. Lossy, so it throws on a non-numeric value unless you pass `{onNonNumeric: 'nan'}`. |
|
|
944
|
-
|
|
945
|
-
`toFloat64Array` refuses by default rather than writing NaN because on real QVDs the lossy case
|
|
946
|
-
is common, not exceptional: in the bundled taxi fixture **no column is strictly numeric** —
|
|
947
|
-
`dropoff_census_tract` is 43 % empty strings, and four columns are 100 % strings. Silently
|
|
948
|
-
turning two fifths of a column into NaN would erase Qlik's distinction between a blank and a
|
|
949
|
-
number. `numericSymbols()` is the cheap conversion; `toFloat64Array()` is the expensive one.
|
|
950
|
-
|
|
951
|
-
#### `static fromDict(dict: object): Promise<QvdDataFrame>`
|
|
952
|
-
|
|
953
|
-
The static method `QvdDataFrame.fromDict` constructs a data frame from a dictionary. The dictionary must contain the columns and
|
|
954
|
-
the actual data as properties. The columns property is an array of strings that contains the names of the fields in the QVD file.
|
|
955
|
-
The data property is an array of arrays that contains the actual data records. The order of the values in the inner arrays
|
|
956
|
-
corresponds to the order of the fields in the QVD file.
|
|
957
|
-
|
|
958
|
-
#### `head(n: number): QvdDataFrame`
|
|
959
|
-
|
|
960
|
-
The method `head` returns the first `n` rows of the data frame.
|
|
961
|
-
|
|
962
|
-
#### `tail(n: number): QvdDataFrame`
|
|
963
|
-
|
|
964
|
-
The method `tail` returns the last `n` rows of the data frame.
|
|
965
|
-
|
|
966
|
-
#### `rows(...args: number): QvdDataFrame`
|
|
967
|
-
|
|
968
|
-
The method `rows` returns a new data frame that contains only the specified rows.
|
|
969
|
-
|
|
970
|
-
#### `at(row: number, column: string): any`
|
|
971
|
-
|
|
972
|
-
The method `at` returns the value at the specified row and column.
|
|
973
|
-
|
|
974
|
-
#### `select(...args: string): QvdDataFrame`
|
|
185
|
+
→ [Metadata reference](https://qvdjs.ptarmiganlabs.com/v0.10/reference/)
|
|
975
186
|
|
|
976
|
-
|
|
187
|
+
## File paths are sandboxed
|
|
977
188
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
The method `toDict` returns the data frame as a dictionary. The dictionary contains the columns and the
|
|
981
|
-
actual data as properties. The columns property is an array of strings that contains the names of the
|
|
982
|
-
fields in the QVD file. The data property is an array of arrays that contains the actual data records.
|
|
983
|
-
The order of the values in the inner arrays corresponds to the order of the fields in the QVD file.
|
|
984
|
-
|
|
985
|
-
#### `toQvd(path: string, options?: object): Promise<void>`
|
|
986
|
-
|
|
987
|
-
The method `toQvd` writes the data frame to a QVD file at the specified path.
|
|
988
|
-
|
|
989
|
-
**Parameters:**
|
|
990
|
-
|
|
991
|
-
- `path` (string): The path where the QVD file should be written.
|
|
992
|
-
- `options` (object, optional): Writing options
|
|
993
|
-
- `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.
|
|
994
|
-
- `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.
|
|
995
|
-
|
|
996
|
-
**Progress Callback:**
|
|
997
|
-
|
|
998
|
-
The `onProgress` callback receives an object with the following properties:
|
|
999
|
-
|
|
1000
|
-
- `stage` (string): Current operation stage - `'symbol-table'`, `'index-table'`, `'header'`, or `'write'`
|
|
1001
|
-
- `current` (number): Current progress value (e.g., rows processed, columns completed)
|
|
1002
|
-
- `total` (number): Total progress value
|
|
1003
|
-
- `percent` (number): Progress percentage (0-100)
|
|
1004
|
-
|
|
1005
|
-
This is particularly useful for large QVD files where write operations can take significant time.
|
|
1006
|
-
|
|
1007
|
-
**Examples:**
|
|
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.
|
|
1008
191
|
|
|
1009
192
|
```javascript
|
|
1010
|
-
|
|
1011
|
-
await
|
|
1012
|
-
|
|
1013
|
-
// Write with security restriction (recommended for production)
|
|
1014
|
-
await df.toQvd('processed/data.qvd', {
|
|
1015
|
-
allowedDir: '/var/output/qvd-files',
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
// Write with progress tracking for large files
|
|
1019
|
-
await df.toQvd('large-output.qvd', {
|
|
1020
|
-
onProgress: (progress) => {
|
|
1021
|
-
console.log(`${progress.stage}: ${progress.percent}% (${progress.current}/${progress.total})`);
|
|
1022
|
-
},
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
// Write with detailed progress bar
|
|
1026
|
-
await df.toQvd('data.qvd', {
|
|
1027
|
-
onProgress: (progress) => {
|
|
1028
|
-
const bar = '█'.repeat(Math.floor(progress.percent / 2)) + '░'.repeat(50 - Math.floor(progress.percent / 2));
|
|
1029
|
-
process.stdout.write(`\r[${progress.stage}] ${bar} ${progress.percent}%`);
|
|
1030
|
-
if (progress.current === progress.total) console.log(' ✓');
|
|
1031
|
-
},
|
|
1032
|
-
});
|
|
193
|
+
await QvdDataFrame.fromQvd('sales.qvd', {allowedDir: '/var/data/qvd'});
|
|
194
|
+
await QvdDataFrame.fromQvd(anyPath, {allowedDir: '/'}); // deliberately unrestricted
|
|
1033
195
|
```
|
|
1034
196
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
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.
|
|
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.
|
|
1038
199
|
|
|
1039
|
-
|
|
200
|
+
→ [Path security](https://qvdjs.ptarmiganlabs.com/v0.10/overview/path-security/)
|
|
1040
201
|
|
|
1041
|
-
|
|
1042
|
-
- `bitOffset`: Bit offset in the index table
|
|
1043
|
-
- `bitWidth`: Bit width in the index table
|
|
1044
|
-
- `bias`: Bias value for index calculation
|
|
1045
|
-
- `noOfSymbols`: Number of unique symbols/values
|
|
1046
|
-
- `offset`: Byte offset in the symbol table
|
|
1047
|
-
- `length`: Byte length in the symbol table
|
|
1048
|
-
- `comment`: Field comment (modifiable)
|
|
1049
|
-
- `numberFormat`: Number format settings (modifiable)
|
|
1050
|
-
- `tags`: Field tags (modifiable)
|
|
202
|
+
## Errors
|
|
1051
203
|
|
|
1052
|
-
|
|
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.
|
|
1053
209
|
|
|
1054
|
-
|
|
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.
|
|
1055
212
|
|
|
1056
|
-
|
|
213
|
+
→ [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v0.10/troubleshooting/)
|
|
1057
214
|
|
|
1058
|
-
|
|
215
|
+
## Known limitations
|
|
1059
216
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
Modifiable properties:
|
|
1063
|
-
|
|
1064
|
-
- `qvBuildNo`: QlikView build number
|
|
1065
|
-
- `creatorDoc`: Document GUID that created the QVD
|
|
1066
|
-
- `createUtcTime`: Creation timestamp
|
|
1067
|
-
- `sourceCreateUtcTime`: Source creation timestamp
|
|
1068
|
-
- `sourceFileUtcTime`: Source file timestamp
|
|
1069
|
-
- `sourceFileSize`: Source file size
|
|
1070
|
-
- `staleUtcTime`: Stale timestamp
|
|
1071
|
-
- `tableName`: Table name
|
|
1072
|
-
- `compression`: Compression method
|
|
1073
|
-
- `comment`: Table comment
|
|
1074
|
-
- `encryptionInfo`: Encryption information
|
|
1075
|
-
- `tableTags`: Table tags
|
|
1076
|
-
- `profilingData`: Profiling data
|
|
1077
|
-
- `lineage`: Data lineage information
|
|
1078
|
-
|
|
1079
|
-
Immutable properties (cannot be modified):
|
|
1080
|
-
|
|
1081
|
-
- `noOfRecords`: Number of records
|
|
1082
|
-
- `recordByteSize`: Record byte size
|
|
1083
|
-
- `offset`: Byte offset in file
|
|
1084
|
-
- `length`: Byte length in file
|
|
1085
|
-
|
|
1086
|
-
#### `setFieldMetadata(fieldName: string, metadata: object): void`
|
|
1087
|
-
|
|
1088
|
-
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.
|
|
1089
|
-
|
|
1090
|
-
Modifiable properties:
|
|
1091
|
-
|
|
1092
|
-
- `comment`: Field comment/description
|
|
1093
|
-
- `numberFormat`: Number format settings (Type, nDec, UseThou, Fmt, Dec, Thou)
|
|
1094
|
-
- `tags`: Field tags (typically used for field classification)
|
|
217
|
+
Honest boundaries rather than an issue list — these are the ones that change what you can do.
|
|
1095
218
|
|
|
1096
|
-
|
|
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. |
|
|
1097
225
|
|
|
1098
|
-
|
|
1099
|
-
- `length`: Byte length in symbol table
|
|
1100
|
-
- `bitOffset`: Bit offset in index table
|
|
1101
|
-
- `bitWidth`: Bit width in index table
|
|
1102
|
-
- `bias`: Bias value
|
|
1103
|
-
- `noOfSymbols`: Number of symbols
|
|
226
|
+
→ [What a round trip preserves](https://qvdjs.ptarmiganlabs.com/v0.10/overview/what-a-round-trip-preserves/)
|
|
1104
227
|
|
|
1105
228
|
## Documentation
|
|
1106
229
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
### For Users
|
|
1110
|
-
|
|
1111
|
-
- **[README.md](./README.md)** (this file) - Quick start guide and API reference
|
|
1112
|
-
- **[QVD_FORMAT.md](./QVD_FORMAT.md)** - Complete QVD file format specification
|
|
1113
|
-
- Binary structure details
|
|
1114
|
-
- Symbol type encodings
|
|
1115
|
-
- Bit packing algorithms
|
|
1116
|
-
- Example file breakdowns
|
|
1117
|
-
|
|
1118
|
-
### For Contributors
|
|
1119
|
-
|
|
1120
|
-
- **[CONTRIBUTING.md](./CONTRIBUTING.md)** - How to contribute to the project
|
|
1121
|
-
- Development setup
|
|
1122
|
-
- Code style guidelines
|
|
1123
|
-
- Commit message conventions
|
|
1124
|
-
- Pull request process
|
|
1125
|
-
- Bug reporting and feature requests
|
|
1126
|
-
|
|
1127
|
-
- **[DEVELOPMENT.md](./DEVELOPMENT.md)** - Technical development guide
|
|
1128
|
-
- Architecture overview
|
|
1129
|
-
- Implementation details
|
|
1130
|
-
- Design patterns
|
|
1131
|
-
- Performance considerations
|
|
1132
|
-
- Error handling strategies
|
|
1133
|
-
- Debugging tips
|
|
1134
|
-
|
|
1135
|
-
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** - High-level architecture
|
|
1136
|
-
- Component diagrams
|
|
1137
|
-
- Class relationships
|
|
1138
|
-
- Data flow visualization
|
|
1139
|
-
- Design decisions and rationale
|
|
1140
|
-
- Extension points
|
|
1141
|
-
- Future considerations
|
|
1142
|
-
|
|
1143
|
-
- **[docs/TESTING.md](./docs/TESTING.md)** - Comprehensive testing guide
|
|
1144
|
-
- Testing philosophy and strategy
|
|
1145
|
-
- How to write unit and integration tests
|
|
1146
|
-
- Performance testing guidelines
|
|
1147
|
-
- Coverage targets
|
|
1148
|
-
- Multi-platform testing setup
|
|
1149
|
-
|
|
1150
|
-
### Quick Links by Task
|
|
1151
|
-
|
|
1152
|
-
| I want to... | See... |
|
|
1153
|
-
| ----------------------- | ------------------------------------------------------------------------- |
|
|
1154
|
-
| Use the library | [README.md](./README.md) - Usage section |
|
|
1155
|
-
| Understand QVD format | [QVD_FORMAT.md](./QVD_FORMAT.md) |
|
|
1156
|
-
| Report a bug | [CONTRIBUTING.md](./CONTRIBUTING.md#reporting-bugs) |
|
|
1157
|
-
| Suggest a feature | [CONTRIBUTING.md](./CONTRIBUTING.md#suggesting-features) |
|
|
1158
|
-
| Contribute code | [CONTRIBUTING.md](./CONTRIBUTING.md) + [DEVELOPMENT.md](./DEVELOPMENT.md) |
|
|
1159
|
-
| Understand architecture | [ARCHITECTURE.md](./ARCHITECTURE.md) |
|
|
1160
|
-
| Write tests | [docs/TESTING.md](./docs/TESTING.md) |
|
|
1161
|
-
| Debug an issue | [DEVELOPMENT.md](./DEVELOPMENT.md#debugging-tips) |
|
|
1162
|
-
|
|
1163
|
-
## Testing
|
|
1164
|
-
|
|
1165
|
-
qvdjs has comprehensive test coverage with automated multi-platform testing. For detailed information about the testing infrastructure, including:
|
|
1166
|
-
|
|
1167
|
-
- Test architecture and coverage breakdown
|
|
1168
|
-
- Multi-platform support (Windows, macOS, Linux)
|
|
1169
|
-
- Security testing approach
|
|
1170
|
-
- Self-hosted runner setup
|
|
1171
|
-
- Performance benchmarking
|
|
1172
|
-
|
|
1173
|
-
See the **[Testing Documentation](./docs/README.md)** in the `docs/` directory.
|
|
1174
|
-
|
|
1175
|
-
Quick links:
|
|
1176
|
-
|
|
1177
|
-
- **[Testing Summary](./docs/TESTING_SUMMARY.md)** - Executive overview and quick start
|
|
1178
|
-
- **[Complete Design](./docs/MULTI_PLATFORM_TEST_DESIGN.md)** - Full technical specification
|
|
1179
|
-
|
|
1180
|
-
### Running Tests
|
|
1181
|
-
|
|
1182
|
-
```bash
|
|
1183
|
-
# Run all tests
|
|
1184
|
-
npm test
|
|
1185
|
-
|
|
1186
|
-
# Run tests with coverage
|
|
1187
|
-
npm run coverage
|
|
230
|
+
**[qvdjs.ptarmiganlabs.com](https://qvdjs.ptarmiganlabs.com)** is the documentation site, and it is
|
|
231
|
+
where everything above is covered properly:
|
|
1188
232
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
233
|
+
| | |
|
|
234
|
+
| ------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
235
|
+
| [Getting started](https://qvdjs.ptarmiganlabs.com/v0.10/getting-started/) | Install, choose an entry point, first read and write |
|
|
236
|
+
| [Guides](https://qvdjs.ptarmiganlabs.com/v0.10/guides/) | One page per task, runnable sample code |
|
|
237
|
+
| [Concepts](https://qvdjs.ptarmiganlabs.com/v0.10/concepts/) | The QVD format, symbols and duals, bit stuffing, the memory model |
|
|
238
|
+
| [Reference](https://qvdjs.ptarmiganlabs.com/v0.10/reference/) | Every class, method, option and error |
|
|
239
|
+
| [Performance](https://qvdjs.ptarmiganlabs.com/v0.10/performance/) | Measured baselines, and how to read them |
|
|
240
|
+
| [Troubleshooting](https://qvdjs.ptarmiganlabs.com/v0.10/troubleshooting/) | What each failure means, and what to do about it |
|
|
1194
241
|
|
|
1195
|
-
|
|
242
|
+
Benchmarks run weekly and publish to
|
|
243
|
+
[ptarmiganlabs.github.io/qvdjs](https://ptarmiganlabs.github.io/qvdjs/).
|
|
1196
244
|
|
|
1197
|
-
|
|
245
|
+
## Contributors
|
|
1198
246
|
|
|
1199
|
-
|
|
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.
|
|
1200
254
|
|
|
1201
|
-
##
|
|
255
|
+
## Licence
|
|
1202
256
|
|
|
1203
|
-
|
|
1204
|
-
- [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
|