qvdjs 0.10.1 → 1.0.0

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