qvdjs 0.9.2 → 0.9.3

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
@@ -16,10 +16,31 @@ The library works with real-world QVD files and maintains compatibility with Qli
16
16
 
17
17
  ---
18
18
 
19
- The _qvdjs_ library provides a simple API for reading/writing Qlik View Data (QVD) files in JavaScript.
20
-
21
- Using this library, it is possible to parse the binary QVD file format and convert it to a JavaScript object
22
- structure and vice versa. The library is written to be used in a Node.js environment exclusively.
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 budget comes from the V8 heap ceiling and any container memory limit — the
34
+ two things that actually kill a process — so it neither refuses work the machine can do nor waves through work
35
+ it cannot. See [QVD File Size Limitations](#qvd-file-size-limitations).
36
+ - **Corrupt files are detected, not silently misread.** Truncated index tables, missing header delimiters and
37
+ out-of-range offsets raise typed errors rather than returning short or fabricated data.
38
+ - **Path traversal protection by default.** File access is confined to the working directory unless you widen it,
39
+ and containment is decided by the filesystem — symlinks are resolved, so a link inside the allowed directory
40
+ pointing outside it is refused. See [Security Considerations](#security-considerations).
41
+
42
+ **What it does not do yet** is worth knowing before you start: see
43
+ [Known limitations](#known-limitations).
23
44
 
24
45
  ---
25
46
 
@@ -31,10 +52,13 @@ structure and vice versa. The library is written to be used in a Node.js environ
31
52
  - [Important: Symbol Table and High-Cardinality Fields](#important-symbol-table-and-high-cardinality-fields)
32
53
  - [Performance Optimizations](#performance-optimizations)
33
54
  - [QVD File Size Limitations](#qvd-file-size-limitations)
55
+ - [The 2 GiB boundary](#the-2-gib-boundary)
34
56
  - [Why Safety Limits Exist](#why-safety-limits-exist)
35
- - [Progress Tracking for Large File Writes](#progress-tracking-for-large-file-writes)
57
+ - [Writing QVD files](#writing-qvd-files)
58
+ - [Progress tracking](#progress-tracking)
36
59
  - [Working with Metadata](#working-with-metadata)
37
60
  - [Security Considerations](#security-considerations)
61
+ - [Known limitations](#known-limitations)
38
62
  - [QVD File Format](#qvd-file-format)
39
63
  - [XML Header](#xml-header)
40
64
  - [Symbol Table](#symbol-table)
@@ -131,8 +155,11 @@ console.log(df.shape); // [1000, numberOfColumns]
131
155
 
132
156
  The QVD format stores data in two parts:
133
157
 
134
- 1. **Symbol table**: Contains ALL unique values for ALL fields (must be fully loaded)
135
- 2. **Index table**: Contains row-by-row indices into the symbol table (can be partially loaded with `maxRows`)
158
+ 1. **Symbol table**: Contains every unique value for every field. It has to be _scanned_ in full, because a
159
+ symbol's length is only known once the previous one has been read — but with `maxRows` the library parses
160
+ only the symbols the requested rows actually reference and steps over the rest. Scanning is cheap; parsing
161
+ is what costs memory.
162
+ 2. **Index table**: Contains row-by-row indices into the symbol table (only the requested rows are read)
136
163
 
137
164
  ⚠️ **Performance Impact of High-Cardinality Fields:**
138
165
 
@@ -142,12 +169,23 @@ If your QVD file contains fields with many unique values (high cardinality), suc
142
169
  - Timestamps with millisecond precision
143
170
  - Unique text fields
144
171
 
145
- The symbol table can become very large and **must be read completely** even when using `maxRows`. This means:
172
+ The symbol table then becomes very large, and has to be scanned end to end even when using `maxRows`. Parsing
173
+ is skipped for symbols the requested rows do not use, so the cost is I/O rather than memory:
146
174
 
147
175
  - **Small symbol table** (fields with reusable values): Fast loading regardless of file size
148
- - Example: A 500MB file with only 100KB symbol table loads in ~100ms for 5000 rows ✅
149
- - **Large symbol table** (fields with unique values per row): Slow loading even with `maxRows`
150
- - Example: A 500MB file with 266MB symbol table takes ~6 seconds for any row count ❌
176
+ - **Large symbol table** (fields with unique values per row): Slower, because the scan is proportional to the
177
+ symbol table's size — but memory stays proportional to the rows you asked for, not to the file
178
+
179
+ You can see what happened on any load through `loadStats` (see [QvdDataFrame](#qvddataframe)):
180
+
181
+ ```javascript
182
+ const df = await QvdDataFrame.fromQvd('large.qvd', {maxRows: 1000});
183
+ console.log(df.loadStats);
184
+ // { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
185
+ // symbolFiltering: true, symbolsKept: 3898 }
186
+ //
187
+ // 3,898 symbols parsed for 1,000 rows, out of a file holding 1.7 million.
188
+ ```
151
189
 
152
190
  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.
153
191
 
@@ -170,24 +208,28 @@ When you specify `maxRows`, the library:
170
208
  2. Parses **only** those symbols from the symbol table
171
209
  3. Skips parsing unused symbols entirely (not just filtering after parsing)
172
210
 
173
- **Performance Impact:**
211
+ This matters most on files whose symbol table is large because most values are unique — exactly the files where
212
+ a naive `maxRows` would still pay for the whole table. Compared to parsing every symbol regardless of `maxRows`,
213
+ loading a few thousand rows from a multi-million-row file has been measured at roughly an order of magnitude
214
+ faster and an order of magnitude smaller in peak memory.
174
215
 
175
- Compared to parsing all symbols regardless of `maxRows`:
176
-
177
- - **~10x faster load times** - Loading 5,000 rows from a 20M row file: 8.3 seconds → 0.9 seconds
178
- - **~10x less memory usage** - Same operation: 1,616 MB → 180 MB
179
- - **Native or better efficiency** - Memory overhead reduced from 6.0x to 0.7x of raw data size
180
-
181
- **Real-World Example:**
216
+ The saving is visible on any file through `loadStats`. On a bundled 1.7-million-row fixture, asking for 1,000
217
+ rows parses 3,898 symbols:
182
218
 
183
219
  ```javascript
184
- // File: orders_20m.qvd (20M rows, 266MB symbol table)
185
- const df = await QvdDataFrame.fromQvd('orders_20m.qvd', {maxRows: 5000});
186
- // Load time: ~0.9 seconds
187
- // Memory usage: ~180 MB
188
- // Only 17,228 out of millions of symbols parsed
220
+ const df = await QvdDataFrame.fromQvd('chicago_taxi_rides_2016_01.qvd', {
221
+ maxRows: 1000,
222
+ symbolFilteringThreshold: 0, // this fixture is well under the 50 MB default
223
+ });
224
+ console.log(df.loadStats);
225
+ // { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
226
+ // symbolFiltering: true, symbolsKept: 3898 }
189
227
  ```
190
228
 
229
+ The optimisation engages automatically once the symbol table passes `symbolFilteringThreshold`, which defaults
230
+ to 50 MB — the point where the extra analysis pass pays for itself. The example lowers it so the path can be
231
+ demonstrated on a small bundled fixture; on a real high-cardinality file it engages on its own.
232
+
191
233
  **Key Benefits:**
192
234
 
193
235
  - Much faster previews of large QVD files
@@ -199,6 +241,28 @@ This optimization is particularly effective for files with many unique values (h
199
241
 
200
242
  ### QVD File Size Limitations
201
243
 
244
+ #### The 2 GiB boundary
245
+
246
+ There is one hard limit worth knowing before anything else: **a full load cannot read a file larger than
247
+ 2 GiB**, because Node caps `fs.readFile` there. It fails with a raw Node error rather than a `QvdError`:
248
+
249
+ ```
250
+ RangeError: File size (2362232013) is greater than 2 GiB
251
+ ```
252
+
253
+ **`maxRows` reads past that boundary**, because the lazy path reads the file in chunks instead:
254
+
255
+ ```javascript
256
+ // A 2.2 GiB file
257
+ await QvdDataFrame.fromQvd(huge, {}); // ❌ RangeError, above
258
+ await QvdDataFrame.fromQvd(huge, {maxRows: 1000}); // ✅ works
259
+ ```
260
+
261
+ So above 2 GiB, `maxRows` is not an optimisation — it is the only way in. Removing the full-load ceiling is
262
+ tracked in [#122](https://github.com/ptarmiganlabs/qvdjs/issues/122).
263
+
264
+ Below that boundary, what limits you is memory rather than file size, which is the rest of this section.
265
+
202
266
  **Simple Explanation:**
203
267
 
204
268
  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.
@@ -231,6 +295,26 @@ The maximum QVD file size you can handle with qvdjs depends on several factors a
231
295
  });
232
296
  ```
233
297
 
298
+ **What the budget is measured against.** The V8 heap ceiling, and any container memory limit reported by
299
+ `process.constrainedMemory()`. Those are the two limits that actually terminate a process — exceeding the heap
300
+ is a fatal, uncatchable V8 error, and exceeding a cgroup limit is a SIGKILL that arrives as exit 137 with no
301
+ JavaScript error at all.
302
+
303
+ What the operating system reports as _free_ memory is deliberately **not** part of it. It is recorded in the
304
+ error context for diagnostics and ignored for the decision, because it does not describe a wall the process
305
+ can hit — a machine with virtual memory gets slower, not fatal — and because the figure is not dependable:
306
+ on macOS it counts only free and speculative pages, excluding the file-cache pages the OS reclaims on demand,
307
+ and readings on one idle machine varied nine-fold within minutes. Budgeting from it meant the same file loaded
308
+ or was refused depending on when you asked.
309
+
310
+ **Turning the check off.** `memorySafetyFactor: 0` disables it entirely, for callers who would rather manage
311
+ memory themselves, or run on a runtime whose limits cannot be measured — Bun reports its current heap as its
312
+ heap limit, so the ceiling it advertises is meaningless.
313
+
314
+ ```javascript
315
+ const df = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
316
+ ```
317
+
234
318
  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.
235
319
 
236
320
  - **Data Characteristics**: The actual memory consumption depends _heavily_ on what's inside your QVD:
@@ -284,7 +368,24 @@ try {
284
368
 
285
369
  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).
286
370
 
287
- ### Progress Tracking for Large File Writes
371
+ ### Writing QVD files
372
+
373
+ `toQvd()` builds the symbol and index tables in memory and writes the file in one pass. Two things are worth
374
+ knowing about it.
375
+
376
+ **Row count is no longer a barrier.** Writing used to fail outright above roughly 122,000 rows with a
377
+ `RangeError: Maximum call stack size exceeded`, because the bit width for each column was derived with
378
+ `Math.max(...oneArgumentPerRow)`. It is now derived from the symbol count in constant time, and writes of
379
+ hundreds of thousands of rows are routine. The regression suite round-trips a 200,000-row data frame and a
380
+ 150,000-row slice of real Qlik output on every CI run, across Linux, Windows and macOS.
381
+
382
+ **Memory scales with the data, not with the file you are writing.** A write holds the whole data frame plus its
383
+ symbol and index tables, so it needs more memory than reading the equivalent file. There is no lazy equivalent
384
+ of `maxRows` for writing — if the data does not fit in the heap, split it across several QVDs.
385
+
386
+ Writes are not atomic; see [Known limitations](#known-limitations).
387
+
388
+ #### Progress tracking
288
389
 
289
390
  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:
290
391
 
@@ -411,7 +512,25 @@ await df.toQvd('processed/sales-filtered.qvd', {
411
512
  - **Null Byte Protection**: Detects and blocks null byte injection attempts
412
513
  - **Default CWD Restriction**: By default, file operations are restricted to the current working directory (CWD) and its subdirectories to prevent path traversal attacks
413
514
  - **Custom Directory Restriction**: Optional `allowedDir` parameter allows you to specify a different base directory
414
- - **Security Errors**: Throws `QvdSecurityError` with detailed context when security violations are detected
515
+ - **Symlinks are resolved**: Containment is decided by the filesystem, not by comparing strings. Both paths are
516
+ resolved through symlinks and compared by device and inode, so a link _inside_ `allowedDir` that points
517
+ outside it is refused — for writes as well as reads. A string comparison sees only the link's own name, still
518
+ under `allowedDir`, and lets it through; that meant a link planted in an upload directory could be used to
519
+ read any file the process could read, and to overwrite and truncate any file it could write.
520
+ - **Case is handled as the filesystem handles it**: comparing by inode means `Qvd` and `qvd` are the same
521
+ directory on a case-insensitive volume and different directories on a case-sensitive one, without guessing
522
+ from `process.platform`. macOS supports both.
523
+ - **Security Errors**: Throws `QvdSecurityError` with detailed context when security violations are detected.
524
+ The context includes a `check` field saying whether the filesystem or the fallback string comparison refused,
525
+ so a rejection of a path that looks contained is traceable to a symlink or a case difference.
526
+
527
+ **A note on `allowedDir` values:** `null`, `undefined` and `''` all fall back to the current working directory
528
+ rather than meaning "no restriction" — callers routinely produce those from optional config or a JSON round
529
+ trip, and silently dropping the sandbox there would be a security hole. To permit an entire volume, pass its
530
+ root explicitly (`'/'` on POSIX, `'C:\\'` on Windows).
531
+
532
+ **Not covered:** this is a check on a path, so it remains open to a symlink swapped in between the check and the
533
+ open. Closing that would require opening with `O_NOFOLLOW` and verifying the descriptor.
415
534
 
416
535
  **Best Practices:**
417
536
 
@@ -454,6 +573,22 @@ try {
454
573
  }
455
574
  ```
456
575
 
576
+ ### Known limitations
577
+
578
+ Honest boundaries rather than an issue list — these are the ones that change what you can do.
579
+
580
+ | Limitation | What it means in practice | Tracked as |
581
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
582
+ | **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) |
583
+ | **The memory estimate is based on symbol table size, not on rows × columns** | A file with a modest symbol table but very many rows can pass every check and then exhaust the heap while materialising rows. Loading tens of millions of rows, size the heap for the result rather than for the file. | [#121](https://github.com/ptarmiganlabs/qvdjs/issues/121) |
584
+ | **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) |
585
+ | **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) |
586
+ | **Writer input is not validated** | Unsupported value types can produce a raw `TypeError`, or a file that is written but does not read back as intended. Feed `fromDict()` numbers, strings and `null`. | [#130](https://github.com/ptarmiganlabs/qvdjs/issues/130) |
587
+
588
+ Numeric-looking strings are currently coerced to numbers on read, which is being reconsidered as a breaking
589
+ change in [#120](https://github.com/ptarmiganlabs/qvdjs/issues/120). Empty and whitespace-only strings are _not_
590
+ coerced — that was a defect, and is fixed.
591
+
457
592
  ## QVD File Format
458
593
 
459
594
  The QVD file format is a binary file format that is used by QlikView to store data. The format is proprietary. However,
@@ -567,13 +702,14 @@ Empty QVDs are fully supported for both reading and writing, maintaining compati
567
702
  The `QvdDataFrame` class represents the data frame stored inside of a finally parsed QVD file. It provides a high-level
568
703
  abstraction access to the QVD file content. This includes meta information as well as access to the actual data records.
569
704
 
570
- | Property | Type | Description |
571
- | -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ |
572
- | `shape` | `number[]` | The shape of the data table. The first element is the number of rows, the second element is the number of columns. |
573
- | `data` | `any[][]` | The actual data records of the QVD file. The first dimension represents the single rows. |
574
- | `columns` | `string[]` | The names of the fields that are contained in the QVD file. |
575
- | `metadata` | `object` | The complete metadata object from the QVD file header, or null if not loaded from a QVD file. |
576
- | `fileMetadata` | `object` | File-level metadata from the QVD header (qvBuildNo, tableName, createUtcTime, etc.). |
705
+ | Property | Type | Description |
706
+ | -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
707
+ | `shape` | `number[]` | The shape of the data table. The first element is the number of rows, the second element is the number of columns. |
708
+ | `data` | `any[][]` | The actual data records of the QVD file. The first dimension represents the single rows. |
709
+ | `columns` | `string[]` | The names of the fields that are contained in the QVD file. |
710
+ | `metadata` | `object` | The complete metadata object from the QVD file header, or null if not loaded from a QVD file. |
711
+ | `fileMetadata` | `object` | File-level metadata from the QVD header (qvBuildNo, tableName, createUtcTime, etc.). |
712
+ | `loadStats` | `object` | What the read did: `symbolTableBytes`, `totalRows`, `rowsLoaded`, `symbolFiltering`, `symbolsKept`. `null` unless the frame came from `fromQvd()`. |
577
713
 
578
714
  #### `static fromQvd(path: string, options?: object): Promise<QvdDataFrame>`
579
715
 
@@ -586,7 +722,8 @@ to a `QvdDataFrame` instance.
586
722
  - `options` (object, optional): Loading options
587
723
  - `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.
588
724
  - `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.
589
- - `memorySafetyFactor` (number, optional): Memory safety factor (0.0-1.0). Default is 0.3 (30%). Determines what percentage of available memory (or V8 heap limit, whichever is smaller) can be used. Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via `--max-old-space-size` to allow processing of proportionally larger files.
725
+ - `memorySafetyFactor` (number, optional): Fraction (0.0-1.0) of the memory budget a load may use. Default is 0.3 (30%). The budget is the smaller of the V8 heap limit and any container memory limit; see [QVD File Size Limitations](#qvd-file-size-limitations). Increase this (e.g., to 0.5 or 0.7) when running with a larger heap via `--max-old-space-size`. **`0` disables the memory check entirely.**
726
+ - `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.
590
727
 
591
728
  **Example:**
592
729
 
@@ -604,8 +741,16 @@ const dfSecure = await QvdDataFrame.fromQvd('reports/sales.qvd', {
604
741
 
605
742
  // Load with increased memory usage for large heap configurations
606
743
  const dfLarge = await QvdDataFrame.fromQvd('large-file.qvd', {
607
- memorySafetyFactor: 0.5, // Use 50% of heap instead of default 30%
744
+ memorySafetyFactor: 0.5, // Use 50% of the budget instead of the default 30%
608
745
  });
746
+
747
+ // Manage memory yourself: skip the check entirely
748
+ const dfUnchecked = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
749
+
750
+ // Inspect what the read actually did
751
+ const preview = await QvdDataFrame.fromQvd('large-file.qvd', {maxRows: 500});
752
+ console.log(preview.loadStats.symbolFiltering); // true when the two-pass path ran
753
+ console.log(preview.loadStats.symbolsKept); // how many symbols it kept
609
754
  ```
610
755
 
611
756
  #### `static fromDict(dict: object): Promise<QvdDataFrame>`
package/dist/index.cjs CHANGED
@@ -746,6 +746,28 @@ var init_bitUtils = __esm({
746
746
  function getHeapLimit() {
747
747
  return v8__default.default.getHeapStatistics().heap_size_limit;
748
748
  }
749
+ function heapLimitIsMeaningful() {
750
+ return !process.versions.bun && !process.versions.deno;
751
+ }
752
+ function getMemoryBudget() {
753
+ const candidates = [];
754
+ if (heapLimitIsMeaningful()) {
755
+ candidates.push({ source: "V8 heap limit", bytes: getHeapLimit() });
756
+ }
757
+ const constrained = typeof process.constrainedMemory === "function" ? process.constrainedMemory() : 0;
758
+ if (constrained > 0 && constrained < os__default.default.totalmem()) {
759
+ candidates.push({ source: "container memory limit", bytes: constrained });
760
+ }
761
+ if (candidates.length === 0) {
762
+ candidates.push({ source: "total system memory", bytes: os__default.default.totalmem() });
763
+ }
764
+ const observed = [{ source: "free memory (os.freemem)", bytes: os__default.default.freemem() }];
765
+ if (typeof process.availableMemory === "function") {
766
+ observed.push({ source: "available memory", bytes: process.availableMemory() });
767
+ }
768
+ const binding = candidates.reduce((lowest, candidate) => candidate.bytes < lowest.bytes ? candidate : lowest);
769
+ return { bytes: binding.bytes, limitedBy: binding.source, candidates, observed };
770
+ }
749
771
  function estimateMemoryUsage(symbolTableSize, maxRows, totalRows) {
750
772
  const FULL_PARSE_OVERHEAD = 6;
751
773
  const MINIMAL_OVERHEAD = 0.01;
@@ -762,11 +784,14 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
762
784
  if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
763
785
  throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
764
786
  }
765
- const availableMemory = os__default.default.freemem();
787
+ if (safetyFactor === 0) {
788
+ return;
789
+ }
790
+ const budget = getMemoryBudget();
766
791
  const heapLimit = getHeapLimit();
792
+ const availableMemory = budget.bytes;
767
793
  const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows);
768
- const effectiveLimit = Math.min(availableMemory, heapLimit);
769
- const maxAllowedMemory = effectiveLimit * safetyFactor;
794
+ const maxAllowedMemory = budget.bytes * safetyFactor;
770
795
  if (estimatedMemory > maxAllowedMemory) {
771
796
  const safeSymbolPercentage = maxAllowedMemory / (symbolTableSize * 6);
772
797
  const safeRowPercentage = Math.pow(safeSymbolPercentage, 2);
@@ -776,9 +801,12 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
776
801
  const availableMB = Math.round(maxAllowedMemory / 1024 / 1024);
777
802
  const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
778
803
  const availableRamMB = Math.round(availableMemory / 1024 / 1024);
779
- const limitingFactor = heapLimit < availableMemory ? "V8 heap limit" : "available RAM";
804
+ const limitingFactor = budget.limitedBy;
805
+ const budgetBreakdown = budget.candidates.map((candidate) => `${candidate.source} ${Math.round(candidate.bytes / 1024 / 1024)}MB`).join(", ");
806
+ const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
807
+ const advice = limitingFactor === "container memory limit" ? `The binding limit is the container's, so raising --max-old-space-size would let V8 grow past it and be killed by the OOM killer instead. Set it below the container limit, raise the limit, or load fewer rows with maxRows (recommended: ${recommendedMaxRows.toLocaleString()} rows or less).` : `Try loading fewer rows using the maxRows parameter (recommended: ${recommendedMaxRows.toLocaleString()} rows or less), or raise the heap with --max-old-space-size.`;
780
808
  throw new exports.QvdValidationError(
781
- `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}: ${heapLimitMB}MB heap / ${availableRamMB}MB RAM). Try loading fewer rows using the maxRows parameter (recommended: ${recommendedMaxRows.toLocaleString()} rows or less).`,
809
+ `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
782
810
  {
783
811
  file: filePath,
784
812
  symbolTableSize,
@@ -788,6 +816,8 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
788
816
  heapLimitMB,
789
817
  availableRamMB,
790
818
  limitingFactor,
819
+ memoryBudget: budget.candidates,
820
+ memoryObserved: budget.observed,
791
821
  totalRows,
792
822
  maxRows,
793
823
  recommendedMaxRows
@@ -1301,14 +1331,22 @@ var init_QvdFileReader = __esm({
1301
1331
  * points outside it is rejected. Defaults to the current working directory. To permit
1302
1332
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1303
1333
  * empty value falls back to the working directory rather than removing the restriction.
1304
- * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines
1305
- * what percentage of available memory (or V8 heap limit, whichever is smaller) can be used
1306
- * for loading QVD files. Default is 0.3 (30%). Increase for larger heap configurations.
1334
+ * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a
1335
+ * load may use. The budget is the smallest of the V8 heap limit, any container memory limit,
1336
+ * and the memory the OS reports as available. Default is 0.3. **Zero disables the memory
1337
+ * check entirely**, which is the escape hatch for runtimes whose limits cannot be measured -
1338
+ * Bun reports its current heap as its heap limit - and for callers who would rather manage
1339
+ * memory themselves than trust the estimate.
1340
+ * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes,
1341
+ * above which a lazy load switches to the two-pass filtering path. The default of 50MB is
1342
+ * the point where the extra analysis pass pays for itself; lower it to use filtering on
1343
+ * smaller files, raise it to keep the simpler single-pass read for longer.
1307
1344
  */
1308
1345
  constructor(filePath, options = {}) {
1309
- const { allowedDir, memorySafetyFactor = 0.3 } = options;
1346
+ const { allowedDir, memorySafetyFactor = 0.3, symbolFilteringThreshold = 50 * 1024 * 1024 } = options;
1310
1347
  this._path = validatePath(filePath, allowedDir);
1311
1348
  this._memorySafetyFactor = memorySafetyFactor;
1349
+ this._symbolFilteringThreshold = symbolFilteringThreshold;
1312
1350
  this._buffer = null;
1313
1351
  this._headerOffset = null;
1314
1352
  this._symbolTableOffset = null;
@@ -1740,15 +1778,12 @@ var init_QvdFileReader = __esm({
1740
1778
  await this._readData(maxRows);
1741
1779
  await this._parseHeader();
1742
1780
  let symbolsToKeep = null;
1781
+ let symbolsKept = null;
1743
1782
  if (maxRows !== null && this._header) {
1744
1783
  const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
1745
- const SYMBOL_FILTERING_THRESHOLD = 50 * 1024 * 1024;
1746
- if (symbolTableLength > SYMBOL_FILTERING_THRESHOLD) {
1784
+ if (symbolTableLength > this._symbolFilteringThreshold) {
1747
1785
  symbolsToKeep = await this._analyzeIndexTableSymbolUsage(maxRows);
1748
- const totalSymbols = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
1749
- console.log(
1750
- `[Phase 2.5 Optimization] Using stream-and-skip parsing: keeping ${totalSymbols} symbols from ${(symbolTableLength / 1024 / 1024).toFixed(1)}MB symbol table`
1751
- );
1786
+ symbolsKept = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
1752
1787
  }
1753
1788
  }
1754
1789
  await this._parseSymbolTable(symbolsToKeep, maxRows);
@@ -1785,7 +1820,14 @@ var init_QvdFileReader = __esm({
1785
1820
  const columns = fields.map((field) => field["FieldName"]);
1786
1821
  const data = this._indexTable.map((_, index) => getRow(index));
1787
1822
  const metadata = this._header["QvdTableHeader"];
1788
- return new exports.QvdDataFrame(data, columns, metadata);
1823
+ const loadStats = {
1824
+ symbolTableBytes: parseInt(this._header["QvdTableHeader"]["Offset"], 10),
1825
+ totalRows: parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10),
1826
+ rowsLoaded: data.length,
1827
+ symbolFiltering: symbolsToKeep !== null,
1828
+ symbolsKept
1829
+ };
1830
+ return new exports.QvdDataFrame(data, columns, metadata, loadStats);
1789
1831
  }
1790
1832
  };
1791
1833
  }
@@ -1802,11 +1844,13 @@ var init_QvdDataFrame = __esm({
1802
1844
  * @param {Array<Array<any>>} data The data of the data frame.
1803
1845
  * @param {Array<string>} columns The columns of the data frame.
1804
1846
  * @param {QvdMetadata|null} metadata The metadata from the QVD file header (optional).
1847
+ * @param {QvdLoadStats|null} loadStats Statistics about the read (optional).
1805
1848
  */
1806
- constructor(data, columns, metadata = null) {
1849
+ constructor(data, columns, metadata = null, loadStats = null) {
1807
1850
  this._data = data;
1808
1851
  this._columns = columns;
1809
1852
  this._metadata = metadata;
1853
+ this._loadStats = loadStats;
1810
1854
  }
1811
1855
  /**
1812
1856
  * Returns the data of the data frame.
@@ -1833,6 +1877,21 @@ var init_QvdDataFrame = __esm({
1833
1877
  get metadata() {
1834
1878
  return this._metadata;
1835
1879
  }
1880
+ /**
1881
+ * Returns statistics about the read that produced this data frame.
1882
+ *
1883
+ * Only a frame returned by fromQvd() carries these; fromDict(), head() and tail() produce
1884
+ * frames that describe no particular read, and report null rather than a stale figure.
1885
+ *
1886
+ * The main use is confirming that a lazy load actually filtered the symbol table:
1887
+ * `symbolFiltering` says whether the two-pass path ran, and `symbolsKept` how many symbols
1888
+ * survived it, which for a small maxRows should be a tiny fraction of the file's total.
1889
+ *
1890
+ * @return {QvdLoadStats|null} Load statistics, or null if this frame did not come from a file.
1891
+ */
1892
+ get loadStats() {
1893
+ return this._loadStats;
1894
+ }
1836
1895
  /**
1837
1896
  * Returns file-level metadata from the QVD header.
1838
1897
  * @return {Object} File-level metadata properties.
@@ -2208,9 +2267,12 @@ var init_QvdDataFrame = __esm({
2208
2267
  * outside it is rejected. Defaults to the current working directory. To permit an entire
2209
2268
  * volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
2210
2269
  * back to the working directory rather than removing the restriction.
2211
- * @param {number} [options.memorySafetyFactor=0.3] Memory safety factor (0.0-1.0). Determines what percentage
2212
- * of available memory (or V8 heap limit, whichever is smaller) can be used. Default is 0.3 (30%).
2213
- * Increase this (e.g., to 0.5 or 0.7) when running with larger heap sizes via --max-old-space-size.
2270
+ * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a load
2271
+ * may use. The budget is the smallest of the V8 heap limit, any container memory limit, and the
2272
+ * memory the OS reports as available. Default is 0.3; raise it when running with a larger heap via
2273
+ * --max-old-space-size. **Zero disables the memory check entirely.**
2274
+ * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes, above which
2275
+ * a lazy load switches to the two-pass filtering path. Defaults to 50MB.
2214
2276
  * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
2215
2277
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
2216
2278
  */
@@ -2218,7 +2280,8 @@ var init_QvdDataFrame = __esm({
2218
2280
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2219
2281
  const readerOptions = {
2220
2282
  allowedDir: options.allowedDir,
2221
- memorySafetyFactor: options.memorySafetyFactor
2283
+ memorySafetyFactor: options.memorySafetyFactor,
2284
+ symbolFilteringThreshold: options.symbolFilteringThreshold
2222
2285
  };
2223
2286
  return await new QvdFileReader2(path3, readerOptions).load(options.maxRows !== void 0 ? options.maxRows : null);
2224
2287
  }