qvdjs 2.0.6 → 2.1.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
@@ -80,6 +80,7 @@ holds.
80
80
  | Rows from a file too large to hold | `QvdDataFrame.iterate(path, {chunkSize})` | Everything, but holds two chunks — a 96 MB heap against 512 MB |
81
81
  | A few columns of a large file | `QvdColumnTable.fromQvd(path)` | Everything, but stops before building rows — 141 MiB against 385 MiB |
82
82
  | Only the schema: names, row count, types | `QvdDataFrame.readMetadata(path)` | The header alone. Constant cost, whatever the file's size |
83
+ | To know whether a read will fit first | `QvdDataFrame.checkRead(path, options)` | The header alone. Answers rather than reads; a read it approves is not refused for memory |
83
84
 
84
85
  ```javascript
85
86
  import {QvdDataFrame, QvdColumnTable} from 'qvdjs';
@@ -87,6 +88,9 @@ import {QvdDataFrame, QvdColumnTable} from 'qvdjs';
87
88
  // What is in this file? Costs the same whether it is 20 KB or 20 GB.
88
89
  const {columns, rowCount} = await QvdDataFrame.readMetadata('sales.qvd');
89
90
 
91
+ // Will reading it fit? Also the header alone, and every suggestion it gives has been checked.
92
+ const {fits, suggestions} = await QvdDataFrame.checkRead('sales.qvd');
93
+
90
94
  // Sum one column without ever building a row.
91
95
  const table = await QvdColumnTable.fromQvd('sales.qvd');
92
96
  let total = 0;
package/dist/index.cjs CHANGED
@@ -453,7 +453,9 @@ var init_optionTypes = __esm({
453
453
  function requireRowCount(value, name, filePath) {
454
454
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
455
455
  throw new exports.QvdValidationError(`${name} must be a non-negative integer`, {
456
+ reason: "option",
456
457
  option: name,
458
+ value,
457
459
  provided: value,
458
460
  type: typeof value,
459
461
  file: filePath
@@ -470,6 +472,9 @@ function normaliseWindow(window, filePath) {
470
472
  }
471
473
  if (typeof window !== "object" || Array.isArray(window)) {
472
474
  throw new exports.QvdValidationError("The row window must be a number, null, or an {offset, limit} object", {
475
+ reason: "option",
476
+ option: "limit",
477
+ value: window,
473
478
  provided: window,
474
479
  type: typeof window,
475
480
  file: filePath
@@ -480,6 +485,9 @@ function normaliseWindow(window, filePath) {
480
485
  const maxRowsGiven = maxRows !== void 0 && maxRows !== null;
481
486
  if (limitGiven && maxRowsGiven) {
482
487
  throw new exports.QvdValidationError("maxRows and limit are two names for the same option; pass one of them, not both", {
488
+ reason: "option",
489
+ option: "limit",
490
+ value: limit,
483
491
  maxRows,
484
492
  limit,
485
493
  file: filePath
@@ -490,6 +498,19 @@ function normaliseWindow(window, filePath) {
490
498
  limit: limitGiven ? requireRowCount(limit, "limit", filePath) : maxRowsGiven ? requireRowCount(maxRows, "maxRows", filePath) : null
491
499
  };
492
500
  }
501
+ function requireChunkSize(chunkSize, filePath) {
502
+ if (typeof chunkSize !== "number" || !Number.isInteger(chunkSize) || chunkSize <= 0) {
503
+ throw new exports.QvdValidationError("chunkSize must be a positive integer", {
504
+ reason: "option",
505
+ option: "chunkSize",
506
+ value: chunkSize,
507
+ provided: chunkSize,
508
+ type: typeof chunkSize,
509
+ file: filePath
510
+ });
511
+ }
512
+ return chunkSize;
513
+ }
493
514
  function resolveWindow(window, totalRows) {
494
515
  const rows = Number.isSafeInteger(totalRows) && totalRows > 0 ? totalRows : 0;
495
516
  const offset = Math.min(window.offset, rows);
@@ -504,6 +525,9 @@ function selectFields(fields, requested, filePath) {
504
525
  }
505
526
  if (!Array.isArray(requested)) {
506
527
  throw new exports.QvdValidationError("fields must be an array of field names", {
528
+ reason: "option",
529
+ option: "fields",
530
+ value: requested,
507
531
  provided: requested,
508
532
  type: typeof requested,
509
533
  file: filePath
@@ -512,6 +536,9 @@ function selectFields(fields, requested, filePath) {
512
536
  const available = fields.map((field) => field["FieldName"]);
513
537
  if (requested.length === 0) {
514
538
  throw new exports.QvdValidationError("fields must name at least one field", {
539
+ reason: "option",
540
+ option: "fields",
541
+ value: requested,
515
542
  availableColumns: available,
516
543
  file: filePath
517
544
  });
@@ -520,6 +547,9 @@ function selectFields(fields, requested, filePath) {
520
547
  return requested.map((name) => {
521
548
  if (typeof name !== "string") {
522
549
  throw new exports.QvdValidationError("Field names must be strings", {
550
+ reason: "option",
551
+ option: "fields",
552
+ value: name,
523
553
  provided: name,
524
554
  type: typeof name,
525
555
  availableColumns: available,
@@ -528,6 +558,9 @@ function selectFields(fields, requested, filePath) {
528
558
  }
529
559
  if (seen.has(name)) {
530
560
  throw new exports.QvdValidationError(`Field '${name}' is listed twice`, {
561
+ reason: "option",
562
+ option: "fields",
563
+ value: name,
531
564
  column: name,
532
565
  fields: requested,
533
566
  file: filePath
@@ -537,6 +570,9 @@ function selectFields(fields, requested, filePath) {
537
570
  const index = available.indexOf(name);
538
571
  if (index === -1) {
539
572
  throw new exports.QvdValidationError(`Column '${name}' does not exist`, {
573
+ reason: "option",
574
+ option: "fields",
575
+ value: name,
540
576
  column: name,
541
577
  availableColumns: available,
542
578
  file: filePath
@@ -551,7 +587,9 @@ function normaliseDuals(value, filePath) {
551
587
  }
552
588
  if (!DUAL_MODES.includes(value)) {
553
589
  throw new exports.QvdValidationError(`duals must be one of ${DUAL_MODES.map((mode) => `'${mode}'`).join(", ")}`, {
590
+ reason: "option",
554
591
  option: "duals",
592
+ value,
555
593
  provided: value,
556
594
  file: filePath
557
595
  });
@@ -590,6 +628,7 @@ var init_readOptions = __esm({
590
628
  init_optionTypes();
591
629
  __name(requireRowCount, "requireRowCount");
592
630
  __name(normaliseWindow, "normaliseWindow");
631
+ __name(requireChunkSize, "requireChunkSize");
593
632
  __name(resolveWindow, "resolveWindow");
594
633
  __name(selectFields, "selectFields");
595
634
  DUAL_MODES = Object.freeze(["number", "text", "both"]);
@@ -2218,22 +2257,56 @@ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, col
2218
2257
  }
2219
2258
  return low;
2220
2259
  }
2221
- function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = 0) {
2222
- if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2223
- throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
2224
- }
2225
- if (safetyFactor === 0) {
2260
+ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = null, readBytes = null) {
2261
+ const answer = checkMemory({
2262
+ symbolTableSize,
2263
+ maxRows,
2264
+ totalRows,
2265
+ safetyFactor,
2266
+ columnCount,
2267
+ materialisesRows,
2268
+ live,
2269
+ bytesHeld,
2270
+ readBytes
2271
+ });
2272
+ if (answer.fits) {
2226
2273
  return;
2227
2274
  }
2228
- const budget = getMemoryBudget();
2275
+ const { message, context } = answer.refusal;
2276
+ throw new exports.QvdValidationError(message, {
2277
+ file: filePath,
2278
+ ...context,
2279
+ reason: "memory",
2280
+ check: answerOf(answer)
2281
+ });
2282
+ }
2283
+ function checkMemory({
2284
+ symbolTableSize,
2285
+ maxRows,
2286
+ totalRows,
2287
+ safetyFactor = 0.8,
2288
+ columnCount = 0,
2289
+ materialisesRows = true,
2290
+ live = null,
2291
+ bytesHeld = null,
2292
+ readBytes = null,
2293
+ measured = null
2294
+ }) {
2295
+ if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2296
+ throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", {
2297
+ safetyFactor,
2298
+ reason: "option",
2299
+ option: "memorySafetyFactor",
2300
+ value: safetyFactor
2301
+ });
2302
+ }
2303
+ const budget = measured ?? getMemoryBudget();
2229
2304
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2230
2305
  const rowsLive = live === null ? null : live.rows;
2231
2306
  const liveRowsPerChunk = live === null ? 1 : live.perChunk;
2232
2307
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2233
2308
  const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows, rowsLive);
2234
- const held = typeof bytesHeld === "number" ? bytesHeld : bytesHeld.held;
2235
- const heldForRows = typeof bytesHeld === "number" ? () => bytesHeld : bytesHeld.forRows;
2236
- const heldForChunk = typeof bytesHeld === "number" ? () => bytesHeld : bytesHeld.forChunk;
2309
+ const { held, forRows: heldForRows, forChunk: heldForChunk } = bytesHeld ?? noBytesHeld;
2237
2310
  const externalMemory = estimateExternalMemory(liveRows, columnCount) + held;
2238
2311
  const bounded = budget.candidates.map((candidate) => {
2239
2312
  const heapOnly = candidate.source === "V8 heap limit";
@@ -2245,11 +2318,26 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2245
2318
  bounds: heapOnly ? "the V8 heap" : "the whole process"
2246
2319
  };
2247
2320
  });
2248
- const exceeded = bounded.filter((candidate) => candidate.needs > candidate.allowed);
2249
- const binding = exceeded.reduce(
2250
- (worst, candidate) => candidate.needs / candidate.allowed > worst.needs / worst.allowed ? candidate : worst,
2251
- exceeded[0]
2321
+ if (safetyFactor === 0) {
2322
+ const lowest = budget.candidates.reduce((least, candidate) => candidate.bytes < least.bytes ? candidate : least);
2323
+ return {
2324
+ fits: true,
2325
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2326
+ // The ceilings are still there and still named - what is missing is any measurement against them.
2327
+ // An earlier version reported `bound: 'none'` here, a third value in a two-value vocabulary that a
2328
+ // caller switching on the documented two would fall straight through.
2329
+ budget: {
2330
+ ...budgetOf(budget, { ...lowest, heapOnly: lowest.source === "V8 heap limit" }, 0),
2331
+ allowedBytes: Infinity
2332
+ },
2333
+ exact: symbolTableSize === 0,
2334
+ suggestions: []
2335
+ };
2336
+ }
2337
+ const tightest = bounded.reduce(
2338
+ (worst, candidate) => candidate.needs / candidate.allowed > worst.needs / worst.allowed ? candidate : worst
2252
2339
  );
2340
+ const binding = tightest.needs > tightest.allowed ? tightest : null;
2253
2341
  const heapLimit = getHeapLimit();
2254
2342
  const availableMemory = binding ? binding.bytes : budget.bytes;
2255
2343
  const estimatedMemory = binding ? binding.needs : heapMemory;
@@ -2306,31 +2394,75 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2306
2394
  } else {
2307
2395
  advice = `Try holding fewer rows using the ${knob} parameter (recommended: ${formatCount(recommendedValue)} rows or less), or raise the heap with --max-old-space-size.`;
2308
2396
  }
2309
- throw new exports.QvdValidationError(
2310
- `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}, which bounds ${limitingScope}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
2311
- {
2312
- file: filePath,
2313
- symbolTableSize,
2314
- symbolTableSizeMB: sizeMB,
2315
- estimatedMemoryMB: estimatedMB,
2316
- availableMemoryMB: availableMB,
2317
- heapLimitMB,
2318
- reportedHeapLimitMB,
2319
- availableRamMB,
2320
- limitingFactor,
2321
- limitingScope,
2322
- memoryBudget: budget.candidates,
2323
- memoryObserved: budget.observed,
2324
- columnCount,
2325
- totalRows,
2326
- maxRows,
2327
- recommendedMaxRows,
2328
- // Only present when a chunk size is what overflowed, so a caller cannot mistake one
2329
- // recommendation for the other.
2330
- ...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
2397
+ const suggestions = [];
2398
+ if (!nothingFits) {
2399
+ suggestions.push({ option: knob, value: recommendedValue });
2400
+ }
2401
+ if (!containerBound) {
2402
+ let needed = Math.ceil(estimatedMemory / safetyFactor / (1024 * 1024));
2403
+ while (Math.max(needed * 1024 * 1024, MINIMUM_BUDGET_BYTES) * safetyFactor < estimatedMemory) {
2404
+ needed += 1;
2331
2405
  }
2332
- );
2406
+ suggestions.push({ nodeOption: "--max-old-space-size", value: needed });
2407
+ }
2408
+ return {
2409
+ fits: false,
2410
+ reason: "memory",
2411
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2412
+ budget: budgetOf(budget, tightest, safetyFactor),
2413
+ // The symbol term is six times the bytes on disk, an overhead measured across files rather than
2414
+ // derived, so any read with symbols in it is an estimate and says so. Only a read that decodes
2415
+ // nothing can be exact.
2416
+ exact: symbolTableSize === 0,
2417
+ suggestions,
2418
+ refusal: {
2419
+ message: `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}, which bounds ${limitingScope}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
2420
+ context: {
2421
+ symbolTableSize,
2422
+ symbolTableSizeMB: sizeMB,
2423
+ estimatedMemoryMB: estimatedMB,
2424
+ availableMemoryMB: availableMB,
2425
+ heapLimitMB,
2426
+ reportedHeapLimitMB,
2427
+ availableRamMB,
2428
+ limitingFactor,
2429
+ limitingScope,
2430
+ memoryBudget: budget.candidates,
2431
+ memoryObserved: budget.observed,
2432
+ columnCount,
2433
+ totalRows,
2434
+ maxRows,
2435
+ recommendedMaxRows,
2436
+ // Only present when a chunk size is what overflowed, so a caller cannot mistake one
2437
+ // recommendation for the other.
2438
+ ...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
2439
+ }
2440
+ }
2441
+ };
2333
2442
  }
2443
+ return {
2444
+ fits: true,
2445
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2446
+ budget: budgetOf(budget, tightest, safetyFactor),
2447
+ exact: symbolTableSize === 0,
2448
+ suggestions: []
2449
+ };
2450
+ }
2451
+ function answerOf(answer) {
2452
+ const { refusal, ...rest } = answer;
2453
+ return rest;
2454
+ }
2455
+ function budgetOf(budget, tightest, safetyFactor) {
2456
+ const processLimit = budget.candidates.find((candidate) => candidate.source !== "V8 heap limit");
2457
+ return {
2458
+ heapBytes: usableOldSpaceLimit(),
2459
+ processBytes: processLimit ? processLimit.bytes : null,
2460
+ bound: tightest.heapOnly ? "heap" : "process",
2461
+ safetyFactor,
2462
+ allowedBytes: tightest.allowed ?? tightest.bytes * safetyFactor,
2463
+ candidates: budget.candidates,
2464
+ observed: budget.observed
2465
+ };
2334
2466
  }
2335
2467
  function formatCount(value) {
2336
2468
  return value.toLocaleString("en-US");
@@ -2352,7 +2484,7 @@ function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount =
2352
2484
  `\u26A0\uFE0F Large symbol table detected (${sizeMB}MB > ${warnMB}MB threshold). This read materialises ${formatCount(rowsToLoad)} of ${formatCount(totalRows)} rows and will use ~${estimatedMB}MB RAM. Reading fewer rows - with limit, maxRows, or a narrower offset window - lowers the row cost, though the symbol table is read in full either way.`
2353
2485
  );
2354
2486
  }
2355
- var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES;
2487
+ var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES, noBytesHeld;
2356
2488
  var init_memoryUtils = __esm({
2357
2489
  "src/util/memoryUtils.js"() {
2358
2490
  init_QvdErrors();
@@ -2370,7 +2502,11 @@ var init_memoryUtils = __esm({
2370
2502
  __name(estimateMemoryUsage, "estimateMemoryUsage");
2371
2503
  __name(recommendedRowsFor, "recommendedRowsFor");
2372
2504
  __name(recommendedChunkFor, "recommendedChunkFor");
2505
+ noBytesHeld = Object.freeze({ held: 0, forRows: /* @__PURE__ */ __name(() => 0, "forRows"), forChunk: /* @__PURE__ */ __name(() => 0, "forChunk") });
2373
2506
  __name(validateMemoryAvailability, "validateMemoryAvailability");
2507
+ __name(checkMemory, "checkMemory");
2508
+ __name(answerOf, "answerOf");
2509
+ __name(budgetOf, "budgetOf");
2374
2510
  __name(formatCount, "formatCount");
2375
2511
  __name(warnLargeSymbolTable, "warnLargeSymbolTable");
2376
2512
  }
@@ -3455,6 +3591,16 @@ async function parseHeaderXml(text, file, stage) {
3455
3591
  }
3456
3592
  return parsed;
3457
3593
  }
3594
+ function symbolBytesOf(selected, symbolTableLength) {
3595
+ const areaBytes = selected.map((field) => headerInteger(field["Length"]));
3596
+ return areaBytes.every((bytes) => Number.isSafeInteger(bytes) && bytes >= 0) ? Math.min(
3597
+ symbolTableLength,
3598
+ areaBytes.reduce((sum, bytes) => sum + bytes, 0)
3599
+ ) : symbolTableLength;
3600
+ }
3601
+ function readPasses(analysisAhead) {
3602
+ return analysisAhead ? 2 : 1;
3603
+ }
3458
3604
  var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, SLICE_BYTES, COUNT_SYMBOLS_PAST; exports.QvdFileReader = void 0;
3459
3605
  var init_QvdFileReader = __esm({
3460
3606
  "src/QvdFileReader.js"() {
@@ -3477,6 +3623,8 @@ var init_QvdFileReader = __esm({
3477
3623
  COUNT_SYMBOLS_PAST = 65536;
3478
3624
  __name(chunksFrom, "chunksFrom");
3479
3625
  __name(parseHeaderXml, "parseHeaderXml");
3626
+ __name(symbolBytesOf, "symbolBytesOf");
3627
+ __name(readPasses, "readPasses");
3480
3628
  exports.QvdFileReader = class {
3481
3629
  static {
3482
3630
  __name(this, "QvdFileReader");
@@ -3818,6 +3966,15 @@ var init_QvdFileReader = __esm({
3818
3966
  const indexTableOffset = symbolTableOffset + symbolTableLength;
3819
3967
  const recordSize = headerInteger(headerObj["QvdTableHeader"]["RecordByteSize"]);
3820
3968
  const totalRows = headerInteger(headerObj["QvdTableHeader"]["NoOfRecords"]);
3969
+ const { size: fileSize } = await handle.stat().catch(failed);
3970
+ this._fileSize = fileSize;
3971
+ this._headerMatchesFile = false;
3972
+ const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
3973
+ (value) => Number.isSafeInteger(value) && value >= 0
3974
+ );
3975
+ if (headerNumbersUsable) {
3976
+ this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3977
+ }
3821
3978
  if (headerOnly) {
3822
3979
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3823
3980
  this._emitProgress("read", 1, 1);
@@ -3826,20 +3983,7 @@ var init_QvdFileReader = __esm({
3826
3983
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3827
3984
  const selected = selectFields(headerFields, this._requestedFields, this._path);
3828
3985
  const columnCount = selected.length;
3829
- const areaBytes = selected.map((field) => headerInteger(field["Length"]));
3830
- const symbolBytes = areaBytes.every((bytes) => Number.isSafeInteger(bytes) && bytes >= 0) ? Math.min(
3831
- symbolTableLength,
3832
- areaBytes.reduce((sum, bytes) => sum + bytes, 0)
3833
- ) : symbolTableLength;
3834
- const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
3835
- (value) => Number.isSafeInteger(value) && value >= 0
3836
- );
3837
- const { size: fileSize } = await handle.stat().catch(failed);
3838
- this._fileSize = fileSize;
3839
- this._headerMatchesFile = false;
3840
- if (headerNumbersUsable) {
3841
- this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3842
- }
3986
+ const symbolBytes = symbolBytesOf(selected, symbolTableLength);
3843
3987
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3844
3988
  const windowRows = resolved.limit;
3845
3989
  if (headerNumbersUsable && this._headerMatchesFile) {
@@ -3858,7 +4002,14 @@ var init_QvdFileReader = __esm({
3858
4002
  recordSize,
3859
4003
  liveRows,
3860
4004
  this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)
3861
- )
4005
+ ),
4006
+ // What it reads, which is not what it holds - the records go through one buffer and are not
4007
+ // kept. Carried so that a refusal's `check` says everything the pre-flight would have said.
4008
+ //
4009
+ // Twice over where the symbol-usage pass is still ahead of it: that pass reads the window's
4010
+ // records to find which symbols the rows use, and the decode then reads them again. Counted
4011
+ // once, the figure understated the I/O of exactly the reads that do the most of it.
4012
+ symbolBytes + readPasses(this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)) * windowRows * recordSize
3862
4013
  );
3863
4014
  }
3864
4015
  if (window.offset === 0 && window.limit === null) {
@@ -3927,8 +4078,9 @@ var init_QvdFileReader = __esm({
3927
4078
  *
3928
4079
  * The areas are counted whole, although the read lets each range go once its fields are parsed, because
3929
4080
  * two ranges are both live when a field of one is parsed between two fields of the other - the order the
3930
- * caller asked for the fields decides it, so the sum is what holds in every order. The slice is what
3931
- * `_forEachSlice` will allocate: `sliceBytes` unless the records are fewer.
4081
+ * caller asked for the fields decides it, so the sum is what holds in every order. The slice is the
4082
+ * buffer `_forEachSlice` will allocate, sized by `_sliceRowsFor` so that the charge and the allocation
4083
+ * are one expression rather than two that agree today.
3932
4084
  *
3933
4085
  * @param {number} symbolBytes Bytes of symbols the read will read.
3934
4086
  * @param {number} rows Records it will read.
@@ -3937,8 +4089,24 @@ var init_QvdFileReader = __esm({
3937
4089
  * @private
3938
4090
  */
3939
4091
  _bytesHeldBy(symbolBytes, rows, recordSize) {
3940
- const records = Number.isSafeInteger(rows) && Number.isSafeInteger(recordSize) ? rows * recordSize : 0;
3941
- return symbolBytes + Math.min(this._sliceBytes, Math.max(0, records));
4092
+ const usable = Number.isSafeInteger(rows) && Number.isSafeInteger(recordSize) && rows > 0 && recordSize > 0;
4093
+ return symbolBytes + (usable ? this._sliceRowsFor(rows, recordSize) * recordSize : 0);
4094
+ }
4095
+ /**
4096
+ * Records the one buffer holds while a read of `rowCount` records goes through it.
4097
+ *
4098
+ * A slice is `sliceBytes` of records, rounded down to a whole record, or every record the read has left
4099
+ * when that is fewer - and at least one, since a read of a record wider than `sliceBytes` still has to
4100
+ * hold that record. The single definition: `_forEachSlice` allocates from it and the memory guard is
4101
+ * charged from it, so a change to how a read slices cannot leave the guard pricing the old rule.
4102
+ *
4103
+ * @param {number} rowCount Records the read will read.
4104
+ * @param {number} recordSize Bytes per record.
4105
+ * @return {number} Records in one slice.
4106
+ * @private
4107
+ */
4108
+ _sliceRowsFor(rowCount, recordSize) {
4109
+ return Math.max(1, Math.min(rowCount, Math.floor(this._sliceBytes / Math.max(1, recordSize))));
3942
4110
  }
3943
4111
  /**
3944
4112
  * What a read holds in bytes of the file, for the memory guard: what it holds now, and what a read
@@ -4154,7 +4322,7 @@ var init_QvdFileReader = __esm({
4154
4322
  return;
4155
4323
  }
4156
4324
  assert4__default.default(this._indexTableOffset !== null, "The QVD file header has not been parsed before its records were read.");
4157
- const sliceRows = Math.max(1, Math.min(rowCount, Math.floor(this._sliceBytes / Math.max(1, recordSize))));
4325
+ const sliceRows = this._sliceRowsFor(rowCount, recordSize);
4158
4326
  const slice = Buffer.alloc(sliceRows * recordSize);
4159
4327
  const requiredBytes = this._indexTableOffset + (firstRow + rowCount) * recordSize;
4160
4328
  for (let done = 0; done < rowCount; done += sliceRows) {
@@ -4407,7 +4575,10 @@ var init_QvdFileReader = __esm({
4407
4575
  fields.length,
4408
4576
  this._materialisesRows,
4409
4577
  liveRows,
4410
- this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false)
4578
+ this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false),
4579
+ // `symbolsToKeep` is non-null exactly when the symbol-usage pass has run, and a pass that has
4580
+ // run has read the window's records once already - so the read's total is two passes over them.
4581
+ symbolBytes + readPasses(symbolsToKeep !== null) * rowsToLoad * recordSize
4411
4582
  );
4412
4583
  }
4413
4584
  warnLargeSymbolTable(symbolBytes, rowsToLoad, totalRows, fields.length, this._materialisesRows);
@@ -4552,6 +4723,95 @@ var init_QvdFileReader = __esm({
4552
4723
  };
4553
4724
  });
4554
4725
  }
4726
+ /**
4727
+ * What a read of this file would cost, and whether it fits, without reading it.
4728
+ *
4729
+ * Reads the header and the file's size and nothing else, at the constant cost of `loadMetadata()`,
4730
+ * then asks the same question a read asks before it allocates anything - through the same function,
4731
+ * from the same numbers. That is the whole point: an answer computed a second way would be a second
4732
+ * opinion, and a read this approves would still be refused.
4733
+ *
4734
+ * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [rawWindow]
4735
+ * The rows the read would cover, spelled any of the ways a read accepts.
4736
+ * @param {{chunkSize?: number|null}} [options] `chunkSize` when the read would be an `iterate()`,
4737
+ * which holds two chunks of rows rather than the window.
4738
+ * @return {Promise<any>} The answer - see `checkMemory`.
4739
+ */
4740
+ async checkRead(rawWindow, { chunkSize = null } = {}) {
4741
+ const window = normaliseWindow(rawWindow, this._path);
4742
+ return await this._closingAfter(async () => {
4743
+ await this._readData({ offset: 0, limit: null }, true);
4744
+ this._emitProgress("header", 0, 1);
4745
+ await this._parseHeader();
4746
+ this._emitProgress("header", 1, 1);
4747
+ this._throwIfAborted();
4748
+ assert4__default.default(
4749
+ this._header && this._selectedFields && this._allFields && this._symbolTableOffset !== null,
4750
+ "The QVD file header has not been parsed."
4751
+ );
4752
+ const header = this._header["QvdTableHeader"];
4753
+ const totalRows = headerInteger(header["NoOfRecords"]);
4754
+ const recordSize = headerInteger(header["RecordByteSize"]);
4755
+ const symbolTableLength = headerInteger(header["Offset"]);
4756
+ const selected = this._selectedFields;
4757
+ validateRecordSize(recordSize, this._path, "checkRead");
4758
+ validateRecordCount(totalRows, this._path, "checkRead");
4759
+ if (!this._headerMatchesFile) {
4760
+ throw new exports.QvdCorruptedError("The file is shorter than its header claims.", {
4761
+ file: this._path,
4762
+ fileSize: this._fileSize,
4763
+ requiredBytes: this._symbolTableOffset + symbolTableLength + totalRows * recordSize,
4764
+ stage: "checkRead"
4765
+ });
4766
+ }
4767
+ const tableLength = this._symbolTableLength();
4768
+ for (const field of this._allFields) {
4769
+ validateFieldMetadata(field, tableLength, this._path);
4770
+ validateFieldBitMetadata(field, recordSize, this._path);
4771
+ }
4772
+ validateSymbolAreas(this._allFields, this._path);
4773
+ const resolved = resolveWindow(window, totalRows);
4774
+ const windowRows = resolved.limit;
4775
+ const liveRows = chunkSize === null ? null : { rows: chunkSize * 2, perChunk: 2 };
4776
+ const analysisAhead = this._analysisWouldRun(window, resolved, totalRows, symbolTableLength);
4777
+ const measured = getMemoryBudget();
4778
+ const ask = /* @__PURE__ */ __name((fields, rows) => {
4779
+ const bytes = symbolBytesOf(fields, symbolTableLength);
4780
+ return checkMemory({
4781
+ measured,
4782
+ symbolTableSize: bytes,
4783
+ maxRows: rows,
4784
+ totalRows,
4785
+ safetyFactor: this._memorySafetyFactor,
4786
+ columnCount: fields.length,
4787
+ materialisesRows: this._materialisesRows,
4788
+ live: liveRows,
4789
+ bytesHeld: this._bytesHeld(bytes, rows, recordSize, liveRows, analysisAhead),
4790
+ // What it reads from the file, which is not what it holds: the symbol areas, and every record
4791
+ // the window covers, read a slice at a time and not kept - twice over where the symbol-usage
4792
+ // pass will run, since it reads them before the decode reads them again.
4793
+ readBytes: bytes + readPasses(analysisAhead) * windowRows * recordSize
4794
+ });
4795
+ }, "ask");
4796
+ const answer = ask(selected, windowRows);
4797
+ if (!answer.fits && selected.length > 1) {
4798
+ const bySize = [...selected].sort(
4799
+ (a, b) => headerInteger(a["Length"]) - headerInteger(b["Length"])
4800
+ );
4801
+ for (let take = selected.length - 1; take >= 1; take -= 1) {
4802
+ const fewer = bySize.slice(0, take);
4803
+ if (ask(fewer, windowRows).fits) {
4804
+ answer.suggestions.push({
4805
+ option: "fields",
4806
+ value: fewer.map((field) => field["FieldName"])
4807
+ });
4808
+ break;
4809
+ }
4810
+ }
4811
+ }
4812
+ return answer;
4813
+ });
4814
+ }
4555
4815
  /**
4556
4816
  * Loads the QVD file into memory and parses it.
4557
4817
  *
@@ -4641,13 +4901,7 @@ var init_QvdFileReader = __esm({
4641
4901
  * @return {AsyncGenerator<QvdDataFrame>} The chunks, in order.
4642
4902
  */
4643
4903
  async *iterateRows(window, chunkSize) {
4644
- if (typeof chunkSize !== "number" || !Number.isInteger(chunkSize) || chunkSize <= 0) {
4645
- throw new exports.QvdValidationError("chunkSize must be a positive integer", {
4646
- provided: chunkSize,
4647
- type: typeof chunkSize,
4648
- file: this._path
4649
- });
4650
- }
4904
+ requireChunkSize(chunkSize, this._path);
4651
4905
  const liveRows = { rows: chunkSize * 2, perChunk: 2 };
4652
4906
  const rows = normaliseWindow(window, this._path);
4653
4907
  this._startRead();
@@ -5562,6 +5816,68 @@ var init_QvdDataFrame = __esm({
5562
5816
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5563
5817
  return await new QvdFileReader2(path5, metadataOptionsFrom(options)).loadMetadata();
5564
5818
  }
5819
+ /**
5820
+ * Answers what a read would cost, and whether it fits, without doing it.
5821
+ *
5822
+ * Takes the options `fromQvd()` takes, plus `as` and `chunkSize` to say which read is being asked
5823
+ * about. Reads the header and the file's size and nothing else, at a cost that does not grow with
5824
+ * the file.
5825
+ *
5826
+ * The answer comes from the same function a read consults before it allocates anything, from the
5827
+ * same numbers, so **a read this approves is not refused later for memory** - and every suggestion
5828
+ * it carries has been read back through the check, so following one gives a read that fits.
5829
+ *
5830
+ * It answers about resources, so it answers only for a header it can trust. A header whose numbers
5831
+ * are not usable, or that claims more than the file holds, is refused as a `QvdCorruptedError` rather
5832
+ * than answered: sizing a read from numbers the file contradicts produced a memory verdict about a
5833
+ * file whose real problem was structural, and it was wrong in both directions - approving a read the
5834
+ * library then refused, and refusing another with advice that was refused too.
5835
+ *
5836
+ * That covers everything a reader can tell from the header: a field area past the end of the symbol
5837
+ * table, two fields claiming one area, a `Bias` that is neither 0 nor -2, a `BitWidth` past 31. Damage
5838
+ * that is not in the header - a value or an index the file has spoiled - is still found only by
5839
+ * reading, and still refused as a `QvdCorruptedError` after this has said the read fits.
5840
+ *
5841
+ * ```js
5842
+ * const answer = await QvdDataFrame.checkRead('huge.qvd', {as: 'columns', fields: ['Amount']});
5843
+ *
5844
+ * if (!answer.fits) {
5845
+ * console.log(answer.reason); // 'memory'
5846
+ * console.log(answer.suggestions); // [{option: 'limit', value: 1250000}, ...]
5847
+ * }
5848
+ * ```
5849
+ *
5850
+ * @param {string} path The QVD file.
5851
+ * @param {object} [options] What `fromQvd()` takes, plus the two below.
5852
+ * @param {'rows'|'columns'} [options.as='rows'] Which read is being asked about: `rows` builds row
5853
+ * arrays and `columns` does not, which is most of what a read costs.
5854
+ * @param {number|null} [options.chunkSize=null] The chunk an `iterate()` would use, which holds two
5855
+ * chunks of rows rather than the whole window.
5856
+ * @return {Promise<any>} The answer: `fits`, `reason` when it does not, `estimate`, `budget`,
5857
+ * `exact` and `suggestions`.
5858
+ * @throws {QvdValidationError} If an option's value is not valid, with `context.reason` of `option`.
5859
+ * @throws {QvdCorruptedError} If the header cannot be read, its numbers are not usable, or it claims
5860
+ * more than the file holds. The read refuses such a file too, though it may name the fault
5861
+ * differently - it gets there by planning the index table, where this gets there from the size.
5862
+ */
5863
+ static async checkRead(path5, options = {}) {
5864
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5865
+ const { as = "rows", chunkSize = null } = options;
5866
+ if (as !== "rows" && as !== "columns") {
5867
+ throw new exports.QvdValidationError("as must be 'rows' or 'columns'", {
5868
+ provided: as,
5869
+ reason: "option",
5870
+ option: "as",
5871
+ value: as,
5872
+ file: path5
5873
+ });
5874
+ }
5875
+ if (chunkSize !== null) {
5876
+ requireChunkSize(chunkSize, path5);
5877
+ }
5878
+ const reader = new QvdFileReader2(path5, { ...readerOptionsFrom(options), materialisesRows: as === "rows" });
5879
+ return await reader.checkRead(windowFrom(options), { chunkSize });
5880
+ }
5565
5881
  /**
5566
5882
  * Constructs a data frame from a dictionary.
5567
5883
  *