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 CHANGED
@@ -7,171 +7,57 @@
7
7
 
8
8
  # qvdjs
9
9
 
10
- > Utility library for reading/writing Qlik Sense and QlikView (QVD) files in JavaScript/Node.js
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 --save
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
- // CommonJS - Traditional Node.js
135
- const {QvdDataFrame} = require('qvdjs');
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
- The package automatically provides the correct format based on your project's configuration.
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
- ## Usage
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
- Below is a quick example how to use _qvdjs_.
30
+ ## ⚠️ This library is based on reverse engineering
143
31
 
144
- ```javascript
145
- import {QvdDataFrame} from 'qvdjs';
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
- The above example loads the _qvdjs_ library and parses an example QVD file. A QVD file is typically loaded using the static
152
- `QvdDataFrame.fromQvd` function of the `QvdDataFrame` class itself. After loading the file's content, numerous methods and properties are available to work with the parsed data.
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
- ### Four ways to open a file
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
- symbol table of every distinct value, then a bit-packed index table of one code per cell — so two
158
- things separate these: how far into the file a read has to go, and how much of it the read then
159
- holds. `iterate` differs from `fromQvd` only on the second, which is why the column below answers
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 of rows — a 96 MB heap against 512 MB, at `chunkSize: 125_000` |
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
- reads and parses the whole symbol table, which grows with the data — and neither does `iterate`,
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
- ```javascript
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
- **What the budget is measured against.** The V8 heap ceiling, and any container memory limit reported by
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
- What the operating system reports as _free_ memory is deliberately **not** part of it. It is recorded in the
383
- error context for diagnostics and ignored for the decision, because it does not describe a wall the process
384
- can hit — a machine with virtual memory gets slower, not fatal — and because the figure is not dependable:
385
- on macOS it counts only free and speculative pages, excluding the file-cache pages the OS reclaims on demand,
386
- and readings on one idle machine varied nine-fold within minutes. Budgeting from it meant the same file loaded
387
- or was refused depending on when you asked.
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
- **Turning the check off.** `memorySafetyFactor: 0` disables it entirely, for callers who would rather manage
390
- memory themselves, or run on a runtime whose limits cannot be measured — Bun reports its current heap as its
391
- heap limit, so the ceiling it advertises is meaningless.
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
- // Process crashes with no chance to recover
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
- ```javascript
462
- try {
463
- const df = await QvdDataFrame.fromQvd('huge-file.qvd');
464
- } catch (error) {
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
- For more technical details about the memory safety system, including specific thresholds and the four-tier protection model, see [docs/DYNAMIC_SAFETY_LIMITS.md](docs/DYNAMIC_SAFETY_LIMITS.md). For practical examples, see [docs/examples/heap-scaling-example.md](docs/examples/heap-scaling-example.md).
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: ['ID', 'Name', 'Value'],
500
- data: largeDataArray, // e.g., 100,000+ rows
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('output.qvd', {
504
- onProgress: (progress) => {
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
- **Progress Object Properties:**
511
-
512
- The `progress` parameter passed to the `onProgress` callback contains the following properties:
513
-
514
- - `stage` (string): Current operation stage being performed
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
- These optimizations can reduce write times by 80-90% for large datasets (100K+ rows), compared to earlier versions of the library.
128
+ → [Writing a QVD](https://qvdjs.ptarmiganlabs.com/v1.0/guides/)
535
129
 
536
- ### Working with Metadata
130
+ ## Reading part of a file
537
131
 
538
- To inspect a file's schema without reading its data, use `readMetadata`. It stops at the XML
539
- header, so it costs the same for a 40 MB file as for a 40 GB one:
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
- import {QvdDataFrame} from 'qvdjs';
543
-
544
- const {columns, rowCount, fields} = await QvdDataFrame.readMetadata('path/to/file.qvd');
545
-
546
- console.log(`${rowCount} rows x ${columns.length} columns`);
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
- // Metadata is preserved when writing
589
- await df.toQvd('path/to/output.qvd');
144
+ console.log(df.loadStats);
145
+ // { symbolTableBytes, totalRows, rowsLoaded, offset, symbolFiltering, symbolsKept }
590
146
  ```
591
147
 
592
- ### Security Considerations
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
- The library includes built-in protection against path traversal attacks.
595
-
596
- **Default Security Behavior:**
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('data/file.qvd'); // ✅ Works (within CWD)
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 instanceof QvdSecurityError) {
672
- console.error('Path traversal blocked:', error.message);
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
- ### Known limitations
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
- Honest boundaries rather than an issue list — these are the ones that change what you can do.
168
+ → [Memory and file size limits](https://qvdjs.ptarmiganlabs.com/v1.0/overview/memory-and-file-size-limits/)
696
169
 
697
- | Limitation | What it means in practice | Tracked as |
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
- **Working with Empty QVDs:**
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
- import {QvdDataFrame} from 'qvdjs';
791
-
792
- // Create an empty QVD with field structure but no data
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
- // Read it back
808
- const loadedDf = await QvdDataFrame.fromQvd('empty.qvd');
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
- Empty QVDs are fully supported for both reading and writing, maintaining compatibility with files created by Qlik Sense and QlikView.
185
+ → [Metadata reference](https://qvdjs.ptarmiganlabs.com/v1.0/reference/)
814
186
 
815
- ## API Documentation
187
+ ## File paths are sandboxed
816
188
 
817
- ### QvdDataFrame
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
- // Load all rows (default behavior)
854
- const df = await QvdDataFrame.fromQvd('path/to/file.qvd');
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
- #### Reading part of a file
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
- Four options control _which_ part of a file is read. They compose: any combination of a window and
885
- a projection is a valid read.
200
+ → [Path security](https://qvdjs.ptarmiganlabs.com/v1.0/overview/path-security/)
886
201
 
887
- | Option | Meaning |
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
- ##### `maxRows` and `limit` are one option
895
-
896
- `maxRows` came first and every released version documents it. `limit` is the spelling that reads
897
- correctly beside `offset`, because "the maximum number of rows" says nothing about where they
898
- start. **Neither is deprecated, and they are the same number**: `5`, `{maxRows: 5}` and
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
- An offset past the end of the file returns **no rows** rather than throwing — the way
913
- `Array.prototype.slice` answers it — so a paging loop stops without a pre-check.
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
- A window deep in a large file costs what the window costs. On the bundled 1.7 M × 20 taxi fixture
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
- ##### `fields` skips the columns you did not ask for
215
+ ## Known limitations
920
216
 
921
- Unselected fields have their symbol areas **skipped, not parsed and discarded**. That is where the
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
- | Class | What it is |
1327
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1328
- | `QvdFileReader` | The reader `fromQvd`, `iterate` and `readMetadata` are thin wrappers over. `new QvdFileReader(path, options)` takes the reader-shaped options — `allowedDir`, `memorySafetyFactor`, `symbolFilteringThreshold`, `fields`, `onProgress`, `signal`, and `materialisesRows` — and exposes `load(window)`, `loadColumnar(window)`, `iterateRows(window, chunkSize)` and `loadMetadata()`. **Pass `materialisesRows: false` whenever you call `loadColumnar` directly**, as `QvdColumnTable.fromQvd` does: it defaults to true, so without it the memory guard charges a columnar read for rows it never builds and refuses reads the high-level call completes. `window` is a number, `null`, or `{offset, limit}`. |
1329
- | `QvdFileWriter` | What `toQvd` uses. `new QvdFileWriter(path, dataFrame, {allowedDir, onProgress})` then `save()`. |
1330
- | `QvdSymbol` | One value as the format stores it — an integer, a double, a string, or a dual pairing a number with its display string. `toPrimaryValue()` is what the reader calls to turn one into a JavaScript value. Useful for inspecting the format; the read paths resolve symbols for you. |
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
- Everything else under `src/` is internal, whatever its visibility.
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
- Comprehensive documentation is available to help you understand, use, and contribute to qvdjs:
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
- ```bash
1413
- # Run all tests
1414
- npm test
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
- # Run tests with coverage
1417
- npm run coverage
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
- Current test coverage: **91.7%** across 95+ tests covering unit, integration, and error handling scenarios.
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
- For detailed information about the release process, see [RELEASING.md](docs/RELEASING.md).
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
- ## Contributors
255
+ ## Licence
1432
256
 
1433
- - [Göran Sander](https://github.com/mountaindude) and [Ptarmigan Labs](https://github.com/ptarmiganlabs) - Creator of qvdjs. Added features to qvd4js library (see below), including improved error handling, expose metadata from XML headers, lazy loading of symbol and index tables, ESM/CJS support, multi-platform testing, TypeScript typings, security hardening, bug fixes, automated npm release process and more
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