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/dist/index.js CHANGED
@@ -441,7 +441,9 @@ var init_optionTypes = __esm({
441
441
  function requireRowCount(value, name, filePath) {
442
442
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
443
443
  throw new QvdValidationError(`${name} must be a non-negative integer`, {
444
+ reason: "option",
444
445
  option: name,
446
+ value,
445
447
  provided: value,
446
448
  type: typeof value,
447
449
  file: filePath
@@ -458,6 +460,9 @@ function normaliseWindow(window, filePath) {
458
460
  }
459
461
  if (typeof window !== "object" || Array.isArray(window)) {
460
462
  throw new QvdValidationError("The row window must be a number, null, or an {offset, limit} object", {
463
+ reason: "option",
464
+ option: "limit",
465
+ value: window,
461
466
  provided: window,
462
467
  type: typeof window,
463
468
  file: filePath
@@ -468,6 +473,9 @@ function normaliseWindow(window, filePath) {
468
473
  const maxRowsGiven = maxRows !== void 0 && maxRows !== null;
469
474
  if (limitGiven && maxRowsGiven) {
470
475
  throw new QvdValidationError("maxRows and limit are two names for the same option; pass one of them, not both", {
476
+ reason: "option",
477
+ option: "limit",
478
+ value: limit,
471
479
  maxRows,
472
480
  limit,
473
481
  file: filePath
@@ -478,6 +486,19 @@ function normaliseWindow(window, filePath) {
478
486
  limit: limitGiven ? requireRowCount(limit, "limit", filePath) : maxRowsGiven ? requireRowCount(maxRows, "maxRows", filePath) : null
479
487
  };
480
488
  }
489
+ function requireChunkSize(chunkSize, filePath) {
490
+ if (typeof chunkSize !== "number" || !Number.isInteger(chunkSize) || chunkSize <= 0) {
491
+ throw new QvdValidationError("chunkSize must be a positive integer", {
492
+ reason: "option",
493
+ option: "chunkSize",
494
+ value: chunkSize,
495
+ provided: chunkSize,
496
+ type: typeof chunkSize,
497
+ file: filePath
498
+ });
499
+ }
500
+ return chunkSize;
501
+ }
481
502
  function resolveWindow(window, totalRows) {
482
503
  const rows = Number.isSafeInteger(totalRows) && totalRows > 0 ? totalRows : 0;
483
504
  const offset = Math.min(window.offset, rows);
@@ -492,6 +513,9 @@ function selectFields(fields, requested, filePath) {
492
513
  }
493
514
  if (!Array.isArray(requested)) {
494
515
  throw new QvdValidationError("fields must be an array of field names", {
516
+ reason: "option",
517
+ option: "fields",
518
+ value: requested,
495
519
  provided: requested,
496
520
  type: typeof requested,
497
521
  file: filePath
@@ -500,6 +524,9 @@ function selectFields(fields, requested, filePath) {
500
524
  const available = fields.map((field) => field["FieldName"]);
501
525
  if (requested.length === 0) {
502
526
  throw new QvdValidationError("fields must name at least one field", {
527
+ reason: "option",
528
+ option: "fields",
529
+ value: requested,
503
530
  availableColumns: available,
504
531
  file: filePath
505
532
  });
@@ -508,6 +535,9 @@ function selectFields(fields, requested, filePath) {
508
535
  return requested.map((name) => {
509
536
  if (typeof name !== "string") {
510
537
  throw new QvdValidationError("Field names must be strings", {
538
+ reason: "option",
539
+ option: "fields",
540
+ value: name,
511
541
  provided: name,
512
542
  type: typeof name,
513
543
  availableColumns: available,
@@ -516,6 +546,9 @@ function selectFields(fields, requested, filePath) {
516
546
  }
517
547
  if (seen.has(name)) {
518
548
  throw new QvdValidationError(`Field '${name}' is listed twice`, {
549
+ reason: "option",
550
+ option: "fields",
551
+ value: name,
519
552
  column: name,
520
553
  fields: requested,
521
554
  file: filePath
@@ -525,6 +558,9 @@ function selectFields(fields, requested, filePath) {
525
558
  const index = available.indexOf(name);
526
559
  if (index === -1) {
527
560
  throw new QvdValidationError(`Column '${name}' does not exist`, {
561
+ reason: "option",
562
+ option: "fields",
563
+ value: name,
528
564
  column: name,
529
565
  availableColumns: available,
530
566
  file: filePath
@@ -539,7 +575,9 @@ function normaliseDuals(value, filePath) {
539
575
  }
540
576
  if (!DUAL_MODES.includes(value)) {
541
577
  throw new QvdValidationError(`duals must be one of ${DUAL_MODES.map((mode) => `'${mode}'`).join(", ")}`, {
578
+ reason: "option",
542
579
  option: "duals",
580
+ value,
543
581
  provided: value,
544
582
  file: filePath
545
583
  });
@@ -578,6 +616,7 @@ var init_readOptions = __esm({
578
616
  init_optionTypes();
579
617
  __name(requireRowCount, "requireRowCount");
580
618
  __name(normaliseWindow, "normaliseWindow");
619
+ __name(requireChunkSize, "requireChunkSize");
581
620
  __name(resolveWindow, "resolveWindow");
582
621
  __name(selectFields, "selectFields");
583
622
  DUAL_MODES = Object.freeze(["number", "text", "both"]);
@@ -2206,22 +2245,56 @@ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, col
2206
2245
  }
2207
2246
  return low;
2208
2247
  }
2209
- function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = 0) {
2210
- if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2211
- throw new QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
2212
- }
2213
- if (safetyFactor === 0) {
2248
+ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = null, readBytes = null) {
2249
+ const answer = checkMemory({
2250
+ symbolTableSize,
2251
+ maxRows,
2252
+ totalRows,
2253
+ safetyFactor,
2254
+ columnCount,
2255
+ materialisesRows,
2256
+ live,
2257
+ bytesHeld,
2258
+ readBytes
2259
+ });
2260
+ if (answer.fits) {
2214
2261
  return;
2215
2262
  }
2216
- const budget = getMemoryBudget();
2263
+ const { message, context } = answer.refusal;
2264
+ throw new QvdValidationError(message, {
2265
+ file: filePath,
2266
+ ...context,
2267
+ reason: "memory",
2268
+ check: answerOf(answer)
2269
+ });
2270
+ }
2271
+ function checkMemory({
2272
+ symbolTableSize,
2273
+ maxRows,
2274
+ totalRows,
2275
+ safetyFactor = 0.8,
2276
+ columnCount = 0,
2277
+ materialisesRows = true,
2278
+ live = null,
2279
+ bytesHeld = null,
2280
+ readBytes = null,
2281
+ measured = null
2282
+ }) {
2283
+ if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2284
+ throw new QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", {
2285
+ safetyFactor,
2286
+ reason: "option",
2287
+ option: "memorySafetyFactor",
2288
+ value: safetyFactor
2289
+ });
2290
+ }
2291
+ const budget = measured ?? getMemoryBudget();
2217
2292
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2218
2293
  const rowsLive = live === null ? null : live.rows;
2219
2294
  const liveRowsPerChunk = live === null ? 1 : live.perChunk;
2220
2295
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2221
2296
  const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows, rowsLive);
2222
- const held = typeof bytesHeld === "number" ? bytesHeld : bytesHeld.held;
2223
- const heldForRows = typeof bytesHeld === "number" ? () => bytesHeld : bytesHeld.forRows;
2224
- const heldForChunk = typeof bytesHeld === "number" ? () => bytesHeld : bytesHeld.forChunk;
2297
+ const { held, forRows: heldForRows, forChunk: heldForChunk } = bytesHeld ?? noBytesHeld;
2225
2298
  const externalMemory = estimateExternalMemory(liveRows, columnCount) + held;
2226
2299
  const bounded = budget.candidates.map((candidate) => {
2227
2300
  const heapOnly = candidate.source === "V8 heap limit";
@@ -2233,11 +2306,26 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2233
2306
  bounds: heapOnly ? "the V8 heap" : "the whole process"
2234
2307
  };
2235
2308
  });
2236
- const exceeded = bounded.filter((candidate) => candidate.needs > candidate.allowed);
2237
- const binding = exceeded.reduce(
2238
- (worst, candidate) => candidate.needs / candidate.allowed > worst.needs / worst.allowed ? candidate : worst,
2239
- exceeded[0]
2309
+ if (safetyFactor === 0) {
2310
+ const lowest = budget.candidates.reduce((least, candidate) => candidate.bytes < least.bytes ? candidate : least);
2311
+ return {
2312
+ fits: true,
2313
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2314
+ // The ceilings are still there and still named - what is missing is any measurement against them.
2315
+ // An earlier version reported `bound: 'none'` here, a third value in a two-value vocabulary that a
2316
+ // caller switching on the documented two would fall straight through.
2317
+ budget: {
2318
+ ...budgetOf(budget, { ...lowest, heapOnly: lowest.source === "V8 heap limit" }, 0),
2319
+ allowedBytes: Infinity
2320
+ },
2321
+ exact: symbolTableSize === 0,
2322
+ suggestions: []
2323
+ };
2324
+ }
2325
+ const tightest = bounded.reduce(
2326
+ (worst, candidate) => candidate.needs / candidate.allowed > worst.needs / worst.allowed ? candidate : worst
2240
2327
  );
2328
+ const binding = tightest.needs > tightest.allowed ? tightest : null;
2241
2329
  const heapLimit = getHeapLimit();
2242
2330
  const availableMemory = binding ? binding.bytes : budget.bytes;
2243
2331
  const estimatedMemory = binding ? binding.needs : heapMemory;
@@ -2294,31 +2382,75 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2294
2382
  } else {
2295
2383
  advice = `Try holding fewer rows using the ${knob} parameter (recommended: ${formatCount(recommendedValue)} rows or less), or raise the heap with --max-old-space-size.`;
2296
2384
  }
2297
- throw new QvdValidationError(
2298
- `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,
2299
- {
2300
- file: filePath,
2301
- symbolTableSize,
2302
- symbolTableSizeMB: sizeMB,
2303
- estimatedMemoryMB: estimatedMB,
2304
- availableMemoryMB: availableMB,
2305
- heapLimitMB,
2306
- reportedHeapLimitMB,
2307
- availableRamMB,
2308
- limitingFactor,
2309
- limitingScope,
2310
- memoryBudget: budget.candidates,
2311
- memoryObserved: budget.observed,
2312
- columnCount,
2313
- totalRows,
2314
- maxRows,
2315
- recommendedMaxRows,
2316
- // Only present when a chunk size is what overflowed, so a caller cannot mistake one
2317
- // recommendation for the other.
2318
- ...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
2385
+ const suggestions = [];
2386
+ if (!nothingFits) {
2387
+ suggestions.push({ option: knob, value: recommendedValue });
2388
+ }
2389
+ if (!containerBound) {
2390
+ let needed = Math.ceil(estimatedMemory / safetyFactor / (1024 * 1024));
2391
+ while (Math.max(needed * 1024 * 1024, MINIMUM_BUDGET_BYTES) * safetyFactor < estimatedMemory) {
2392
+ needed += 1;
2319
2393
  }
2320
- );
2394
+ suggestions.push({ nodeOption: "--max-old-space-size", value: needed });
2395
+ }
2396
+ return {
2397
+ fits: false,
2398
+ reason: "memory",
2399
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2400
+ budget: budgetOf(budget, tightest, safetyFactor),
2401
+ // The symbol term is six times the bytes on disk, an overhead measured across files rather than
2402
+ // derived, so any read with symbols in it is an estimate and says so. Only a read that decodes
2403
+ // nothing can be exact.
2404
+ exact: symbolTableSize === 0,
2405
+ suggestions,
2406
+ refusal: {
2407
+ 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,
2408
+ context: {
2409
+ symbolTableSize,
2410
+ symbolTableSizeMB: sizeMB,
2411
+ estimatedMemoryMB: estimatedMB,
2412
+ availableMemoryMB: availableMB,
2413
+ heapLimitMB,
2414
+ reportedHeapLimitMB,
2415
+ availableRamMB,
2416
+ limitingFactor,
2417
+ limitingScope,
2418
+ memoryBudget: budget.candidates,
2419
+ memoryObserved: budget.observed,
2420
+ columnCount,
2421
+ totalRows,
2422
+ maxRows,
2423
+ recommendedMaxRows,
2424
+ // Only present when a chunk size is what overflowed, so a caller cannot mistake one
2425
+ // recommendation for the other.
2426
+ ...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
2427
+ }
2428
+ }
2429
+ };
2321
2430
  }
2431
+ return {
2432
+ fits: true,
2433
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2434
+ budget: budgetOf(budget, tightest, safetyFactor),
2435
+ exact: symbolTableSize === 0,
2436
+ suggestions: []
2437
+ };
2438
+ }
2439
+ function answerOf(answer) {
2440
+ const { refusal, ...rest } = answer;
2441
+ return rest;
2442
+ }
2443
+ function budgetOf(budget, tightest, safetyFactor) {
2444
+ const processLimit = budget.candidates.find((candidate) => candidate.source !== "V8 heap limit");
2445
+ return {
2446
+ heapBytes: usableOldSpaceLimit(),
2447
+ processBytes: processLimit ? processLimit.bytes : null,
2448
+ bound: tightest.heapOnly ? "heap" : "process",
2449
+ safetyFactor,
2450
+ allowedBytes: tightest.allowed ?? tightest.bytes * safetyFactor,
2451
+ candidates: budget.candidates,
2452
+ observed: budget.observed
2453
+ };
2322
2454
  }
2323
2455
  function formatCount(value) {
2324
2456
  return value.toLocaleString("en-US");
@@ -2340,7 +2472,7 @@ function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount =
2340
2472
  `\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.`
2341
2473
  );
2342
2474
  }
2343
- var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES;
2475
+ var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES, noBytesHeld;
2344
2476
  var init_memoryUtils = __esm({
2345
2477
  "src/util/memoryUtils.js"() {
2346
2478
  init_QvdErrors();
@@ -2358,7 +2490,11 @@ var init_memoryUtils = __esm({
2358
2490
  __name(estimateMemoryUsage, "estimateMemoryUsage");
2359
2491
  __name(recommendedRowsFor, "recommendedRowsFor");
2360
2492
  __name(recommendedChunkFor, "recommendedChunkFor");
2493
+ noBytesHeld = Object.freeze({ held: 0, forRows: /* @__PURE__ */ __name(() => 0, "forRows"), forChunk: /* @__PURE__ */ __name(() => 0, "forChunk") });
2361
2494
  __name(validateMemoryAvailability, "validateMemoryAvailability");
2495
+ __name(checkMemory, "checkMemory");
2496
+ __name(answerOf, "answerOf");
2497
+ __name(budgetOf, "budgetOf");
2362
2498
  __name(formatCount, "formatCount");
2363
2499
  __name(warnLargeSymbolTable, "warnLargeSymbolTable");
2364
2500
  }
@@ -3443,6 +3579,16 @@ async function parseHeaderXml(text, file, stage) {
3443
3579
  }
3444
3580
  return parsed;
3445
3581
  }
3582
+ function symbolBytesOf(selected, symbolTableLength) {
3583
+ const areaBytes = selected.map((field) => headerInteger(field["Length"]));
3584
+ return areaBytes.every((bytes) => Number.isSafeInteger(bytes) && bytes >= 0) ? Math.min(
3585
+ symbolTableLength,
3586
+ areaBytes.reduce((sum, bytes) => sum + bytes, 0)
3587
+ ) : symbolTableLength;
3588
+ }
3589
+ function readPasses(analysisAhead) {
3590
+ return analysisAhead ? 2 : 1;
3591
+ }
3446
3592
  var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, SLICE_BYTES, COUNT_SYMBOLS_PAST, QvdFileReader;
3447
3593
  var init_QvdFileReader = __esm({
3448
3594
  "src/QvdFileReader.js"() {
@@ -3465,6 +3611,8 @@ var init_QvdFileReader = __esm({
3465
3611
  COUNT_SYMBOLS_PAST = 65536;
3466
3612
  __name(chunksFrom, "chunksFrom");
3467
3613
  __name(parseHeaderXml, "parseHeaderXml");
3614
+ __name(symbolBytesOf, "symbolBytesOf");
3615
+ __name(readPasses, "readPasses");
3468
3616
  QvdFileReader = class {
3469
3617
  static {
3470
3618
  __name(this, "QvdFileReader");
@@ -3806,6 +3954,15 @@ var init_QvdFileReader = __esm({
3806
3954
  const indexTableOffset = symbolTableOffset + symbolTableLength;
3807
3955
  const recordSize = headerInteger(headerObj["QvdTableHeader"]["RecordByteSize"]);
3808
3956
  const totalRows = headerInteger(headerObj["QvdTableHeader"]["NoOfRecords"]);
3957
+ const { size: fileSize } = await handle.stat().catch(failed);
3958
+ this._fileSize = fileSize;
3959
+ this._headerMatchesFile = false;
3960
+ const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
3961
+ (value) => Number.isSafeInteger(value) && value >= 0
3962
+ );
3963
+ if (headerNumbersUsable) {
3964
+ this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3965
+ }
3809
3966
  if (headerOnly) {
3810
3967
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3811
3968
  this._emitProgress("read", 1, 1);
@@ -3814,20 +3971,7 @@ var init_QvdFileReader = __esm({
3814
3971
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3815
3972
  const selected = selectFields(headerFields, this._requestedFields, this._path);
3816
3973
  const columnCount = selected.length;
3817
- const areaBytes = selected.map((field) => headerInteger(field["Length"]));
3818
- const symbolBytes = areaBytes.every((bytes) => Number.isSafeInteger(bytes) && bytes >= 0) ? Math.min(
3819
- symbolTableLength,
3820
- areaBytes.reduce((sum, bytes) => sum + bytes, 0)
3821
- ) : symbolTableLength;
3822
- const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
3823
- (value) => Number.isSafeInteger(value) && value >= 0
3824
- );
3825
- const { size: fileSize } = await handle.stat().catch(failed);
3826
- this._fileSize = fileSize;
3827
- this._headerMatchesFile = false;
3828
- if (headerNumbersUsable) {
3829
- this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3830
- }
3974
+ const symbolBytes = symbolBytesOf(selected, symbolTableLength);
3831
3975
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3832
3976
  const windowRows = resolved.limit;
3833
3977
  if (headerNumbersUsable && this._headerMatchesFile) {
@@ -3846,7 +3990,14 @@ var init_QvdFileReader = __esm({
3846
3990
  recordSize,
3847
3991
  liveRows,
3848
3992
  this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)
3849
- )
3993
+ ),
3994
+ // What it reads, which is not what it holds - the records go through one buffer and are not
3995
+ // kept. Carried so that a refusal's `check` says everything the pre-flight would have said.
3996
+ //
3997
+ // Twice over where the symbol-usage pass is still ahead of it: that pass reads the window's
3998
+ // records to find which symbols the rows use, and the decode then reads them again. Counted
3999
+ // once, the figure understated the I/O of exactly the reads that do the most of it.
4000
+ symbolBytes + readPasses(this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)) * windowRows * recordSize
3850
4001
  );
3851
4002
  }
3852
4003
  if (window.offset === 0 && window.limit === null) {
@@ -3915,8 +4066,9 @@ var init_QvdFileReader = __esm({
3915
4066
  *
3916
4067
  * The areas are counted whole, although the read lets each range go once its fields are parsed, because
3917
4068
  * two ranges are both live when a field of one is parsed between two fields of the other - the order the
3918
- * caller asked for the fields decides it, so the sum is what holds in every order. The slice is what
3919
- * `_forEachSlice` will allocate: `sliceBytes` unless the records are fewer.
4069
+ * caller asked for the fields decides it, so the sum is what holds in every order. The slice is the
4070
+ * buffer `_forEachSlice` will allocate, sized by `_sliceRowsFor` so that the charge and the allocation
4071
+ * are one expression rather than two that agree today.
3920
4072
  *
3921
4073
  * @param {number} symbolBytes Bytes of symbols the read will read.
3922
4074
  * @param {number} rows Records it will read.
@@ -3925,8 +4077,24 @@ var init_QvdFileReader = __esm({
3925
4077
  * @private
3926
4078
  */
3927
4079
  _bytesHeldBy(symbolBytes, rows, recordSize) {
3928
- const records = Number.isSafeInteger(rows) && Number.isSafeInteger(recordSize) ? rows * recordSize : 0;
3929
- return symbolBytes + Math.min(this._sliceBytes, Math.max(0, records));
4080
+ const usable = Number.isSafeInteger(rows) && Number.isSafeInteger(recordSize) && rows > 0 && recordSize > 0;
4081
+ return symbolBytes + (usable ? this._sliceRowsFor(rows, recordSize) * recordSize : 0);
4082
+ }
4083
+ /**
4084
+ * Records the one buffer holds while a read of `rowCount` records goes through it.
4085
+ *
4086
+ * A slice is `sliceBytes` of records, rounded down to a whole record, or every record the read has left
4087
+ * when that is fewer - and at least one, since a read of a record wider than `sliceBytes` still has to
4088
+ * hold that record. The single definition: `_forEachSlice` allocates from it and the memory guard is
4089
+ * charged from it, so a change to how a read slices cannot leave the guard pricing the old rule.
4090
+ *
4091
+ * @param {number} rowCount Records the read will read.
4092
+ * @param {number} recordSize Bytes per record.
4093
+ * @return {number} Records in one slice.
4094
+ * @private
4095
+ */
4096
+ _sliceRowsFor(rowCount, recordSize) {
4097
+ return Math.max(1, Math.min(rowCount, Math.floor(this._sliceBytes / Math.max(1, recordSize))));
3930
4098
  }
3931
4099
  /**
3932
4100
  * What a read holds in bytes of the file, for the memory guard: what it holds now, and what a read
@@ -4142,7 +4310,7 @@ var init_QvdFileReader = __esm({
4142
4310
  return;
4143
4311
  }
4144
4312
  assert4(this._indexTableOffset !== null, "The QVD file header has not been parsed before its records were read.");
4145
- const sliceRows = Math.max(1, Math.min(rowCount, Math.floor(this._sliceBytes / Math.max(1, recordSize))));
4313
+ const sliceRows = this._sliceRowsFor(rowCount, recordSize);
4146
4314
  const slice = Buffer.alloc(sliceRows * recordSize);
4147
4315
  const requiredBytes = this._indexTableOffset + (firstRow + rowCount) * recordSize;
4148
4316
  for (let done = 0; done < rowCount; done += sliceRows) {
@@ -4395,7 +4563,10 @@ var init_QvdFileReader = __esm({
4395
4563
  fields.length,
4396
4564
  this._materialisesRows,
4397
4565
  liveRows,
4398
- this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false)
4566
+ this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false),
4567
+ // `symbolsToKeep` is non-null exactly when the symbol-usage pass has run, and a pass that has
4568
+ // run has read the window's records once already - so the read's total is two passes over them.
4569
+ symbolBytes + readPasses(symbolsToKeep !== null) * rowsToLoad * recordSize
4399
4570
  );
4400
4571
  }
4401
4572
  warnLargeSymbolTable(symbolBytes, rowsToLoad, totalRows, fields.length, this._materialisesRows);
@@ -4540,6 +4711,95 @@ var init_QvdFileReader = __esm({
4540
4711
  };
4541
4712
  });
4542
4713
  }
4714
+ /**
4715
+ * What a read of this file would cost, and whether it fits, without reading it.
4716
+ *
4717
+ * Reads the header and the file's size and nothing else, at the constant cost of `loadMetadata()`,
4718
+ * then asks the same question a read asks before it allocates anything - through the same function,
4719
+ * from the same numbers. That is the whole point: an answer computed a second way would be a second
4720
+ * opinion, and a read this approves would still be refused.
4721
+ *
4722
+ * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [rawWindow]
4723
+ * The rows the read would cover, spelled any of the ways a read accepts.
4724
+ * @param {{chunkSize?: number|null}} [options] `chunkSize` when the read would be an `iterate()`,
4725
+ * which holds two chunks of rows rather than the window.
4726
+ * @return {Promise<any>} The answer - see `checkMemory`.
4727
+ */
4728
+ async checkRead(rawWindow, { chunkSize = null } = {}) {
4729
+ const window = normaliseWindow(rawWindow, this._path);
4730
+ return await this._closingAfter(async () => {
4731
+ await this._readData({ offset: 0, limit: null }, true);
4732
+ this._emitProgress("header", 0, 1);
4733
+ await this._parseHeader();
4734
+ this._emitProgress("header", 1, 1);
4735
+ this._throwIfAborted();
4736
+ assert4(
4737
+ this._header && this._selectedFields && this._allFields && this._symbolTableOffset !== null,
4738
+ "The QVD file header has not been parsed."
4739
+ );
4740
+ const header = this._header["QvdTableHeader"];
4741
+ const totalRows = headerInteger(header["NoOfRecords"]);
4742
+ const recordSize = headerInteger(header["RecordByteSize"]);
4743
+ const symbolTableLength = headerInteger(header["Offset"]);
4744
+ const selected = this._selectedFields;
4745
+ validateRecordSize(recordSize, this._path, "checkRead");
4746
+ validateRecordCount(totalRows, this._path, "checkRead");
4747
+ if (!this._headerMatchesFile) {
4748
+ throw new QvdCorruptedError("The file is shorter than its header claims.", {
4749
+ file: this._path,
4750
+ fileSize: this._fileSize,
4751
+ requiredBytes: this._symbolTableOffset + symbolTableLength + totalRows * recordSize,
4752
+ stage: "checkRead"
4753
+ });
4754
+ }
4755
+ const tableLength = this._symbolTableLength();
4756
+ for (const field of this._allFields) {
4757
+ validateFieldMetadata(field, tableLength, this._path);
4758
+ validateFieldBitMetadata(field, recordSize, this._path);
4759
+ }
4760
+ validateSymbolAreas(this._allFields, this._path);
4761
+ const resolved = resolveWindow(window, totalRows);
4762
+ const windowRows = resolved.limit;
4763
+ const liveRows = chunkSize === null ? null : { rows: chunkSize * 2, perChunk: 2 };
4764
+ const analysisAhead = this._analysisWouldRun(window, resolved, totalRows, symbolTableLength);
4765
+ const measured = getMemoryBudget();
4766
+ const ask = /* @__PURE__ */ __name((fields, rows) => {
4767
+ const bytes = symbolBytesOf(fields, symbolTableLength);
4768
+ return checkMemory({
4769
+ measured,
4770
+ symbolTableSize: bytes,
4771
+ maxRows: rows,
4772
+ totalRows,
4773
+ safetyFactor: this._memorySafetyFactor,
4774
+ columnCount: fields.length,
4775
+ materialisesRows: this._materialisesRows,
4776
+ live: liveRows,
4777
+ bytesHeld: this._bytesHeld(bytes, rows, recordSize, liveRows, analysisAhead),
4778
+ // What it reads from the file, which is not what it holds: the symbol areas, and every record
4779
+ // the window covers, read a slice at a time and not kept - twice over where the symbol-usage
4780
+ // pass will run, since it reads them before the decode reads them again.
4781
+ readBytes: bytes + readPasses(analysisAhead) * windowRows * recordSize
4782
+ });
4783
+ }, "ask");
4784
+ const answer = ask(selected, windowRows);
4785
+ if (!answer.fits && selected.length > 1) {
4786
+ const bySize = [...selected].sort(
4787
+ (a, b) => headerInteger(a["Length"]) - headerInteger(b["Length"])
4788
+ );
4789
+ for (let take = selected.length - 1; take >= 1; take -= 1) {
4790
+ const fewer = bySize.slice(0, take);
4791
+ if (ask(fewer, windowRows).fits) {
4792
+ answer.suggestions.push({
4793
+ option: "fields",
4794
+ value: fewer.map((field) => field["FieldName"])
4795
+ });
4796
+ break;
4797
+ }
4798
+ }
4799
+ }
4800
+ return answer;
4801
+ });
4802
+ }
4543
4803
  /**
4544
4804
  * Loads the QVD file into memory and parses it.
4545
4805
  *
@@ -4629,13 +4889,7 @@ var init_QvdFileReader = __esm({
4629
4889
  * @return {AsyncGenerator<QvdDataFrame>} The chunks, in order.
4630
4890
  */
4631
4891
  async *iterateRows(window, chunkSize) {
4632
- if (typeof chunkSize !== "number" || !Number.isInteger(chunkSize) || chunkSize <= 0) {
4633
- throw new QvdValidationError("chunkSize must be a positive integer", {
4634
- provided: chunkSize,
4635
- type: typeof chunkSize,
4636
- file: this._path
4637
- });
4638
- }
4892
+ requireChunkSize(chunkSize, this._path);
4639
4893
  const liveRows = { rows: chunkSize * 2, perChunk: 2 };
4640
4894
  const rows = normaliseWindow(window, this._path);
4641
4895
  this._startRead();
@@ -5550,6 +5804,68 @@ var init_QvdDataFrame = __esm({
5550
5804
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5551
5805
  return await new QvdFileReader2(path5, metadataOptionsFrom(options)).loadMetadata();
5552
5806
  }
5807
+ /**
5808
+ * Answers what a read would cost, and whether it fits, without doing it.
5809
+ *
5810
+ * Takes the options `fromQvd()` takes, plus `as` and `chunkSize` to say which read is being asked
5811
+ * about. Reads the header and the file's size and nothing else, at a cost that does not grow with
5812
+ * the file.
5813
+ *
5814
+ * The answer comes from the same function a read consults before it allocates anything, from the
5815
+ * same numbers, so **a read this approves is not refused later for memory** - and every suggestion
5816
+ * it carries has been read back through the check, so following one gives a read that fits.
5817
+ *
5818
+ * It answers about resources, so it answers only for a header it can trust. A header whose numbers
5819
+ * are not usable, or that claims more than the file holds, is refused as a `QvdCorruptedError` rather
5820
+ * than answered: sizing a read from numbers the file contradicts produced a memory verdict about a
5821
+ * file whose real problem was structural, and it was wrong in both directions - approving a read the
5822
+ * library then refused, and refusing another with advice that was refused too.
5823
+ *
5824
+ * That covers everything a reader can tell from the header: a field area past the end of the symbol
5825
+ * table, two fields claiming one area, a `Bias` that is neither 0 nor -2, a `BitWidth` past 31. Damage
5826
+ * that is not in the header - a value or an index the file has spoiled - is still found only by
5827
+ * reading, and still refused as a `QvdCorruptedError` after this has said the read fits.
5828
+ *
5829
+ * ```js
5830
+ * const answer = await QvdDataFrame.checkRead('huge.qvd', {as: 'columns', fields: ['Amount']});
5831
+ *
5832
+ * if (!answer.fits) {
5833
+ * console.log(answer.reason); // 'memory'
5834
+ * console.log(answer.suggestions); // [{option: 'limit', value: 1250000}, ...]
5835
+ * }
5836
+ * ```
5837
+ *
5838
+ * @param {string} path The QVD file.
5839
+ * @param {object} [options] What `fromQvd()` takes, plus the two below.
5840
+ * @param {'rows'|'columns'} [options.as='rows'] Which read is being asked about: `rows` builds row
5841
+ * arrays and `columns` does not, which is most of what a read costs.
5842
+ * @param {number|null} [options.chunkSize=null] The chunk an `iterate()` would use, which holds two
5843
+ * chunks of rows rather than the whole window.
5844
+ * @return {Promise<any>} The answer: `fits`, `reason` when it does not, `estimate`, `budget`,
5845
+ * `exact` and `suggestions`.
5846
+ * @throws {QvdValidationError} If an option's value is not valid, with `context.reason` of `option`.
5847
+ * @throws {QvdCorruptedError} If the header cannot be read, its numbers are not usable, or it claims
5848
+ * more than the file holds. The read refuses such a file too, though it may name the fault
5849
+ * differently - it gets there by planning the index table, where this gets there from the size.
5850
+ */
5851
+ static async checkRead(path5, options = {}) {
5852
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5853
+ const { as = "rows", chunkSize = null } = options;
5854
+ if (as !== "rows" && as !== "columns") {
5855
+ throw new QvdValidationError("as must be 'rows' or 'columns'", {
5856
+ provided: as,
5857
+ reason: "option",
5858
+ option: "as",
5859
+ value: as,
5860
+ file: path5
5861
+ });
5862
+ }
5863
+ if (chunkSize !== null) {
5864
+ requireChunkSize(chunkSize, path5);
5865
+ }
5866
+ const reader = new QvdFileReader2(path5, { ...readerOptionsFrom(options), materialisesRows: as === "rows" });
5867
+ return await reader.checkRead(windowFrom(options), { chunkSize });
5868
+ }
5553
5869
  /**
5554
5870
  * Constructs a data frame from a dictionary.
5555
5871
  *