qvdjs 2.0.6 → 2.2.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"]);
@@ -2146,13 +2185,13 @@ function estimateRowMemory(rows, columnCount) {
2146
2185
  }
2147
2186
  return BASE_BYTES + rows * (ROW_BASE_BYTES + PER_CELL_BYTES * columnCount);
2148
2187
  }
2149
- function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, rowsLive = null) {
2188
+ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, rowsLive = null, wholeSymbols = false) {
2150
2189
  const FULL_PARSE_OVERHEAD = 6;
2151
2190
  const MINIMAL_OVERHEAD = 0.01;
2152
2191
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2153
2192
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2154
2193
  const rowMemory = materialisesRows ? estimateRowMemory(liveRows, columnCount) : BASE_BYTES;
2155
- if (maxRows === null || maxRows >= totalRows) {
2194
+ if (maxRows === null || maxRows >= totalRows || wholeSymbols) {
2156
2195
  return symbolTableSize * FULL_PARSE_OVERHEAD + rowMemory;
2157
2196
  }
2158
2197
  const rowPercentage = maxRows / totalRows;
@@ -2161,8 +2200,8 @@ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount =
2161
2200
  const skippedSymbolsMemory = symbolTableSize * (1 - symbolPercentage) * MINIMAL_OVERHEAD;
2162
2201
  return keptSymbolsMemory + skippedSymbolsMemory + rowMemory;
2163
2202
  }
2164
- function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true, includeExternal = false) {
2165
- const costOf = /* @__PURE__ */ __name((rows) => estimateMemoryUsage(symbolTableSize, rows, totalRows, columnCount, materialisesRows) + (includeExternal ? estimateExternalMemory(Math.min(rows, totalRows), columnCount) : 0), "costOf");
2203
+ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true, includeExternal = false, wholeSymbols = false) {
2204
+ const costOf = /* @__PURE__ */ __name((rows) => estimateMemoryUsage(symbolTableSize, rows, totalRows, columnCount, materialisesRows, null, wholeSymbols) + (includeExternal ? estimateExternalMemory(Math.min(rows, totalRows), columnCount) : 0), "costOf");
2166
2205
  if (costOf(totalRows) <= budget) {
2167
2206
  return totalRows;
2168
2207
  }
@@ -2181,11 +2220,19 @@ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, mat
2181
2220
  }
2182
2221
  return low;
2183
2222
  }
2184
- function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, columnCount, liveRowsPerChunk = 1, includeExternal = false) {
2223
+ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, columnCount, liveRowsPerChunk = 1, includeExternal = false, wholeSymbols = false) {
2185
2224
  const covered = windowRows === null || windowRows >= totalRows ? totalRows : windowRows;
2186
2225
  const fits = /* @__PURE__ */ __name((chunk) => {
2187
2226
  const live = Math.min(chunk * liveRowsPerChunk, covered);
2188
- const cost = estimateMemoryUsage(symbolTableSize, windowRows, totalRows, columnCount, true, chunk * liveRowsPerChunk) + (includeExternal ? estimateExternalMemory(live, columnCount) : 0);
2227
+ const cost = estimateMemoryUsage(
2228
+ symbolTableSize,
2229
+ windowRows,
2230
+ totalRows,
2231
+ columnCount,
2232
+ true,
2233
+ chunk * liveRowsPerChunk,
2234
+ wholeSymbols
2235
+ ) + (includeExternal ? estimateExternalMemory(live, columnCount) : 0);
2189
2236
  return cost <= budget;
2190
2237
  }, "fits");
2191
2238
  if (fits(covered)) {
@@ -2206,22 +2253,66 @@ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, col
2206
2253
  }
2207
2254
  return low;
2208
2255
  }
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) {
2256
+ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = null, readBytes = null, wholeSymbols = false) {
2257
+ const answer = checkMemory({
2258
+ wholeSymbols,
2259
+ symbolTableSize,
2260
+ maxRows,
2261
+ totalRows,
2262
+ safetyFactor,
2263
+ columnCount,
2264
+ materialisesRows,
2265
+ live,
2266
+ bytesHeld,
2267
+ readBytes
2268
+ });
2269
+ if (answer.fits) {
2214
2270
  return;
2215
2271
  }
2216
- const budget = getMemoryBudget();
2272
+ const { message, context } = answer.refusal;
2273
+ throw new QvdValidationError(message, {
2274
+ file: filePath,
2275
+ ...context,
2276
+ reason: "memory",
2277
+ check: answerOf(answer)
2278
+ });
2279
+ }
2280
+ function checkMemory({
2281
+ symbolTableSize,
2282
+ maxRows,
2283
+ totalRows,
2284
+ safetyFactor = 0.8,
2285
+ columnCount = 0,
2286
+ materialisesRows = true,
2287
+ live = null,
2288
+ bytesHeld = null,
2289
+ readBytes = null,
2290
+ measured = null,
2291
+ wholeSymbols = false
2292
+ }) {
2293
+ if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2294
+ throw new QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", {
2295
+ safetyFactor,
2296
+ reason: "option",
2297
+ option: "memorySafetyFactor",
2298
+ value: safetyFactor
2299
+ });
2300
+ }
2301
+ const budget = measured ?? getMemoryBudget();
2217
2302
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2218
2303
  const rowsLive = live === null ? null : live.rows;
2219
2304
  const liveRowsPerChunk = live === null ? 1 : live.perChunk;
2220
2305
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2221
- 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;
2306
+ const heapMemory = estimateMemoryUsage(
2307
+ symbolTableSize,
2308
+ maxRows,
2309
+ totalRows,
2310
+ columnCount,
2311
+ materialisesRows,
2312
+ rowsLive,
2313
+ wholeSymbols
2314
+ );
2315
+ const { held, forRows: heldForRows, forChunk: heldForChunk } = bytesHeld ?? noBytesHeld;
2225
2316
  const externalMemory = estimateExternalMemory(liveRows, columnCount) + held;
2226
2317
  const bounded = budget.candidates.map((candidate) => {
2227
2318
  const heapOnly = candidate.source === "V8 heap limit";
@@ -2233,11 +2324,26 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2233
2324
  bounds: heapOnly ? "the V8 heap" : "the whole process"
2234
2325
  };
2235
2326
  });
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]
2327
+ if (safetyFactor === 0) {
2328
+ const lowest = budget.candidates.reduce((least, candidate) => candidate.bytes < least.bytes ? candidate : least);
2329
+ return {
2330
+ fits: true,
2331
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2332
+ // The ceilings are still there and still named - what is missing is any measurement against them.
2333
+ // An earlier version reported `bound: 'none'` here, a third value in a two-value vocabulary that a
2334
+ // caller switching on the documented two would fall straight through.
2335
+ budget: {
2336
+ ...budgetOf(budget, { ...lowest, heapOnly: lowest.source === "V8 heap limit" }, 0),
2337
+ allowedBytes: Infinity
2338
+ },
2339
+ exact: symbolTableSize === 0,
2340
+ suggestions: []
2341
+ };
2342
+ }
2343
+ const tightest = bounded.reduce(
2344
+ (worst, candidate) => candidate.needs / candidate.allowed > worst.needs / worst.allowed ? candidate : worst
2240
2345
  );
2346
+ const binding = tightest.needs > tightest.allowed ? tightest : null;
2241
2347
  const heapLimit = getHeapLimit();
2242
2348
  const availableMemory = binding ? binding.bytes : budget.bytes;
2243
2349
  const estimatedMemory = binding ? binding.needs : heapMemory;
@@ -2251,7 +2357,8 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2251
2357
  totalRows,
2252
2358
  columnCount,
2253
2359
  materialisesRows,
2254
- includeExternal
2360
+ includeExternal,
2361
+ wholeSymbols
2255
2362
  ), "fitting");
2256
2363
  const firstGuess = fitting(liveRows);
2257
2364
  const over = fitting(firstGuess);
@@ -2277,7 +2384,8 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2277
2384
  totalRows,
2278
2385
  columnCount,
2279
2386
  liveRowsPerChunk,
2280
- includeExternal
2387
+ includeExternal,
2388
+ wholeSymbols
2281
2389
  ), "chunkFitting");
2282
2390
  const callersChunk = chunked ? Math.max(1, Math.floor(rowsLive / Math.max(1, liveRowsPerChunk))) : 0;
2283
2391
  const firstChunk = chunked ? chunkFitting(callersChunk) : 0;
@@ -2294,42 +2402,94 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2294
2402
  } else {
2295
2403
  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
2404
  }
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 } : {}
2405
+ const suggestions = [];
2406
+ if (!nothingFits) {
2407
+ suggestions.push({ option: knob, value: recommendedValue });
2408
+ }
2409
+ if (!containerBound) {
2410
+ let needed = Math.ceil(estimatedMemory / safetyFactor / (1024 * 1024));
2411
+ while (Math.max(needed * 1024 * 1024, MINIMUM_BUDGET_BYTES) * safetyFactor < estimatedMemory) {
2412
+ needed += 1;
2319
2413
  }
2320
- );
2414
+ suggestions.push({ nodeOption: "--max-old-space-size", value: needed });
2415
+ }
2416
+ return {
2417
+ fits: false,
2418
+ reason: "memory",
2419
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2420
+ budget: budgetOf(budget, tightest, safetyFactor),
2421
+ // The symbol term is six times the bytes on disk, an overhead measured across files rather than
2422
+ // derived, so any read with symbols in it is an estimate and says so. Only a read that decodes
2423
+ // nothing can be exact.
2424
+ exact: symbolTableSize === 0,
2425
+ suggestions,
2426
+ refusal: {
2427
+ 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,
2428
+ context: {
2429
+ symbolTableSize,
2430
+ symbolTableSizeMB: sizeMB,
2431
+ estimatedMemoryMB: estimatedMB,
2432
+ availableMemoryMB: availableMB,
2433
+ heapLimitMB,
2434
+ reportedHeapLimitMB,
2435
+ availableRamMB,
2436
+ limitingFactor,
2437
+ limitingScope,
2438
+ memoryBudget: budget.candidates,
2439
+ memoryObserved: budget.observed,
2440
+ columnCount,
2441
+ totalRows,
2442
+ maxRows,
2443
+ recommendedMaxRows,
2444
+ // Only present when a chunk size is what overflowed, so a caller cannot mistake one
2445
+ // recommendation for the other.
2446
+ ...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
2447
+ }
2448
+ }
2449
+ };
2321
2450
  }
2451
+ return {
2452
+ fits: true,
2453
+ estimate: { heapBytes: heapMemory, externalBytes: externalMemory, readBytes },
2454
+ budget: budgetOf(budget, tightest, safetyFactor),
2455
+ exact: symbolTableSize === 0,
2456
+ suggestions: []
2457
+ };
2458
+ }
2459
+ function answerOf(answer) {
2460
+ const { refusal, ...rest } = answer;
2461
+ return rest;
2462
+ }
2463
+ function budgetOf(budget, tightest, safetyFactor) {
2464
+ const processLimit = budget.candidates.find((candidate) => candidate.source !== "V8 heap limit");
2465
+ return {
2466
+ heapBytes: usableOldSpaceLimit(),
2467
+ processBytes: processLimit ? processLimit.bytes : null,
2468
+ bound: tightest.heapOnly ? "heap" : "process",
2469
+ safetyFactor,
2470
+ allowedBytes: tightest.allowed ?? tightest.bytes * safetyFactor,
2471
+ candidates: budget.candidates,
2472
+ observed: budget.observed
2473
+ };
2322
2474
  }
2323
2475
  function formatCount(value) {
2324
2476
  return value.toLocaleString("en-US");
2325
2477
  }
2326
- function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true) {
2478
+ function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, wholeSymbols = false) {
2327
2479
  const LARGE_SYMBOL_TABLE_WARNING = usableOldSpaceLimit() * 0.125;
2328
2480
  if (symbolTableSize <= LARGE_SYMBOL_TABLE_WARNING) {
2329
2481
  return;
2330
2482
  }
2331
2483
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2332
- const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows);
2484
+ const estimatedMemory = estimateMemoryUsage(
2485
+ symbolTableSize,
2486
+ maxRows,
2487
+ totalRows,
2488
+ columnCount,
2489
+ materialisesRows,
2490
+ null,
2491
+ wholeSymbols
2492
+ );
2333
2493
  if (estimatedMemory <= LARGE_SYMBOL_TABLE_WARNING) {
2334
2494
  return;
2335
2495
  }
@@ -2340,7 +2500,7 @@ function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount =
2340
2500
  `\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
2501
  );
2342
2502
  }
2343
- var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES;
2503
+ var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES, noBytesHeld;
2344
2504
  var init_memoryUtils = __esm({
2345
2505
  "src/util/memoryUtils.js"() {
2346
2506
  init_QvdErrors();
@@ -2358,7 +2518,11 @@ var init_memoryUtils = __esm({
2358
2518
  __name(estimateMemoryUsage, "estimateMemoryUsage");
2359
2519
  __name(recommendedRowsFor, "recommendedRowsFor");
2360
2520
  __name(recommendedChunkFor, "recommendedChunkFor");
2521
+ noBytesHeld = Object.freeze({ held: 0, forRows: /* @__PURE__ */ __name(() => 0, "forRows"), forChunk: /* @__PURE__ */ __name(() => 0, "forChunk") });
2361
2522
  __name(validateMemoryAvailability, "validateMemoryAvailability");
2523
+ __name(checkMemory, "checkMemory");
2524
+ __name(answerOf, "answerOf");
2525
+ __name(budgetOf, "budgetOf");
2362
2526
  __name(formatCount, "formatCount");
2363
2527
  __name(warnLargeSymbolTable, "warnLargeSymbolTable");
2364
2528
  }
@@ -3443,6 +3607,36 @@ async function parseHeaderXml(text, file, stage) {
3443
3607
  }
3444
3608
  return parsed;
3445
3609
  }
3610
+ function symbolBytesOf(selected, symbolTableLength) {
3611
+ const areaBytes = selected.map((field) => headerInteger(field["Length"]));
3612
+ return areaBytes.every((bytes) => Number.isSafeInteger(bytes) && bytes >= 0) ? Math.min(
3613
+ symbolTableLength,
3614
+ areaBytes.reduce((sum, bytes) => sum + bytes, 0)
3615
+ ) : symbolTableLength;
3616
+ }
3617
+ function readPasses(analysisAhead) {
3618
+ return analysisAhead ? 2 : 1;
3619
+ }
3620
+ function validateWatchers(onProgress, signal, path5) {
3621
+ if (onProgress !== void 0 && typeof onProgress !== "function") {
3622
+ throw new QvdValidationError("onProgress must be a function", {
3623
+ provided: onProgress,
3624
+ type: typeof onProgress,
3625
+ reason: "option",
3626
+ option: "onProgress",
3627
+ file: path5
3628
+ });
3629
+ }
3630
+ if (signal !== void 0 && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean")) {
3631
+ throw new QvdValidationError("signal must be an AbortSignal", {
3632
+ provided: signal,
3633
+ type: typeof signal,
3634
+ reason: "option",
3635
+ option: "signal",
3636
+ file: path5
3637
+ });
3638
+ }
3639
+ }
3446
3640
  var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, SLICE_BYTES, COUNT_SYMBOLS_PAST, QvdFileReader;
3447
3641
  var init_QvdFileReader = __esm({
3448
3642
  "src/QvdFileReader.js"() {
@@ -3465,6 +3659,9 @@ var init_QvdFileReader = __esm({
3465
3659
  COUNT_SYMBOLS_PAST = 65536;
3466
3660
  __name(chunksFrom, "chunksFrom");
3467
3661
  __name(parseHeaderXml, "parseHeaderXml");
3662
+ __name(symbolBytesOf, "symbolBytesOf");
3663
+ __name(readPasses, "readPasses");
3664
+ __name(validateWatchers, "validateWatchers");
3468
3665
  QvdFileReader = class {
3469
3666
  static {
3470
3667
  __name(this, "QvdFileReader");
@@ -3545,20 +3742,7 @@ var init_QvdFileReader = __esm({
3545
3742
  });
3546
3743
  }
3547
3744
  this._sliceBytes = sliceBytes;
3548
- if (onProgress !== void 0 && typeof onProgress !== "function") {
3549
- throw new QvdValidationError("onProgress must be a function", {
3550
- provided: onProgress,
3551
- type: typeof onProgress,
3552
- file: this._path
3553
- });
3554
- }
3555
- if (signal !== void 0 && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean")) {
3556
- throw new QvdValidationError("signal must be an AbortSignal", {
3557
- provided: signal,
3558
- type: typeof signal,
3559
- file: this._path
3560
- });
3561
- }
3745
+ validateWatchers(onProgress, signal, this._path);
3562
3746
  this._requestedFields = fields === void 0 ? null : fields;
3563
3747
  this._onProgress = onProgress;
3564
3748
  this._signal = signal;
@@ -3567,6 +3751,8 @@ var init_QvdFileReader = __esm({
3567
3751
  this._failed = null;
3568
3752
  this._reading = false;
3569
3753
  this._symbolAreas = null;
3754
+ this._symbolCache = null;
3755
+ this._cachedFor = null;
3570
3756
  this._headerOffset = null;
3571
3757
  this._symbolTableOffset = null;
3572
3758
  this._indexTableOffset = null;
@@ -3578,6 +3764,7 @@ var init_QvdFileReader = __esm({
3578
3764
  this._indexColumns = null;
3579
3765
  this._rowsDecoded = 0;
3580
3766
  this._fileSize = null;
3767
+ this._fileIdentity = null;
3581
3768
  this._headerMatchesFile = false;
3582
3769
  }
3583
3770
  /**
@@ -3806,6 +3993,16 @@ var init_QvdFileReader = __esm({
3806
3993
  const indexTableOffset = symbolTableOffset + symbolTableLength;
3807
3994
  const recordSize = headerInteger(headerObj["QvdTableHeader"]["RecordByteSize"]);
3808
3995
  const totalRows = headerInteger(headerObj["QvdTableHeader"]["NoOfRecords"]);
3996
+ const { size: fileSize, ino, dev, mtimeMs } = await handle.stat().catch(failed);
3997
+ this._fileSize = fileSize;
3998
+ this._fileIdentity = `${dev}:${ino}:${mtimeMs}:${fileSize}`;
3999
+ this._headerMatchesFile = false;
4000
+ const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
4001
+ (value) => Number.isSafeInteger(value) && value >= 0
4002
+ );
4003
+ if (headerNumbersUsable) {
4004
+ this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
4005
+ }
3809
4006
  if (headerOnly) {
3810
4007
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3811
4008
  this._emitProgress("read", 1, 1);
@@ -3814,20 +4011,8 @@ var init_QvdFileReader = __esm({
3814
4011
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3815
4012
  const selected = selectFields(headerFields, this._requestedFields, this._path);
3816
4013
  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
- }
4014
+ const symbolBytes = symbolBytesOf(this._fieldsHeldAfter(selected, headerFields), symbolTableLength);
4015
+ const readSymbolBytes = symbolBytesOf(this._fieldsReadBy(selected), symbolTableLength);
3831
4016
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3832
4017
  const windowRows = resolved.limit;
3833
4018
  if (headerNumbersUsable && this._headerMatchesFile) {
@@ -3841,12 +4026,23 @@ var init_QvdFileReader = __esm({
3841
4026
  this._materialisesRows,
3842
4027
  liveRows,
3843
4028
  this._bytesHeld(
3844
- symbolBytes,
4029
+ readSymbolBytes,
3845
4030
  windowRows,
3846
4031
  recordSize,
3847
4032
  liveRows,
3848
- this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)
3849
- )
4033
+ this._analysisAhead(window, resolved, totalRows, symbolTableLength)
4034
+ ),
4035
+ // What it reads, which is not what it holds - the records go through one buffer and are not
4036
+ // kept. Carried so that a refusal's `check` says everything the pre-flight would have said.
4037
+ //
4038
+ // Twice over where the symbol-usage pass is still ahead of it: that pass reads the window's
4039
+ // records to find which symbols the rows use, and the decode then reads them again. Counted
4040
+ // once, the figure understated the I/O of exactly the reads that do the most of it.
4041
+ readSymbolBytes + readPasses(this._analysisAhead(window, resolved, totalRows, symbolTableLength)) * windowRows * recordSize,
4042
+ // A paging read keeps whole columns, so the estimate must not discount its symbols as a window's
4043
+ // sample of them - see `estimateMemoryUsage`. Under-charging is the direction that ends in a
4044
+ // heap-limit abort rather than an error.
4045
+ this._symbolCache !== null
3850
4046
  );
3851
4047
  }
3852
4048
  if (window.offset === 0 && window.limit === null) {
@@ -3915,8 +4111,9 @@ var init_QvdFileReader = __esm({
3915
4111
  *
3916
4112
  * The areas are counted whole, although the read lets each range go once its fields are parsed, because
3917
4113
  * 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.
4114
+ * caller asked for the fields decides it, so the sum is what holds in every order. The slice is the
4115
+ * buffer `_forEachSlice` will allocate, sized by `_sliceRowsFor` so that the charge and the allocation
4116
+ * are one expression rather than two that agree today.
3920
4117
  *
3921
4118
  * @param {number} symbolBytes Bytes of symbols the read will read.
3922
4119
  * @param {number} rows Records it will read.
@@ -3925,8 +4122,24 @@ var init_QvdFileReader = __esm({
3925
4122
  * @private
3926
4123
  */
3927
4124
  _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));
4125
+ const usable = Number.isSafeInteger(rows) && Number.isSafeInteger(recordSize) && rows > 0 && recordSize > 0;
4126
+ return symbolBytes + (usable ? this._sliceRowsFor(rows, recordSize) * recordSize : 0);
4127
+ }
4128
+ /**
4129
+ * Records the one buffer holds while a read of `rowCount` records goes through it.
4130
+ *
4131
+ * A slice is `sliceBytes` of records, rounded down to a whole record, or every record the read has left
4132
+ * when that is fewer - and at least one, since a read of a record wider than `sliceBytes` still has to
4133
+ * hold that record. The single definition: `_forEachSlice` allocates from it and the memory guard is
4134
+ * charged from it, so a change to how a read slices cannot leave the guard pricing the old rule.
4135
+ *
4136
+ * @param {number} rowCount Records the read will read.
4137
+ * @param {number} recordSize Bytes per record.
4138
+ * @return {number} Records in one slice.
4139
+ * @private
4140
+ */
4141
+ _sliceRowsFor(rowCount, recordSize) {
4142
+ return Math.max(1, Math.min(rowCount, Math.floor(this._sliceBytes / Math.max(1, recordSize))));
3930
4143
  }
3931
4144
  /**
3932
4145
  * What a read holds in bytes of the file, for the memory guard: what it holds now, and what a read
@@ -4014,6 +4227,191 @@ var init_QvdFileReader = __esm({
4014
4227
  const chunkRows = liveRows === null ? windowRows : Math.max(1, Math.floor(liveRows.rows / Math.max(1, liveRows.perChunk)));
4015
4228
  return analysisAhead ? Math.max(windowRows, chunkRows) : chunkRows;
4016
4229
  }
4230
+ /**
4231
+ * The fields this reader will be holding the symbols of once this read has finished.
4232
+ *
4233
+ * The ones it selects, and - while paging - the ones it decoded for an earlier page and kept. That
4234
+ * union is what the memory checks have to be sized by, because it is what is live: a reader four
4235
+ * pages into a wide file holds four columns' values whether or not this page asks about them, and a
4236
+ * check sized by this page alone would approve a fifth column that does not fit beside them.
4237
+ *
4238
+ * The same set for every check, so the ceilings, the guard and the pre-flight cannot disagree about
4239
+ * what a paging read costs. Without a cache it is just the selection, which is what every one-shot
4240
+ * read has always been sized by.
4241
+ *
4242
+ * @param {Array<any>} selected The fields this read selects.
4243
+ * @param {Array<any>} all Every field in the header, to find a cached one by name.
4244
+ * @return {Array<any>} The fields whose symbols will be live.
4245
+ * @private
4246
+ */
4247
+ _fieldsHeldAfter(selected, all) {
4248
+ if (this._symbolCache === null || this._symbolCache.size === 0) {
4249
+ return selected;
4250
+ }
4251
+ const names = new Set(selected.map((field) => field["FieldName"]));
4252
+ const cached = all.filter(
4253
+ (field) => !names.has(field["FieldName"]) && this._symbolCache !== null && this._symbolCache.has(field["FieldName"])
4254
+ );
4255
+ return [...selected, ...cached];
4256
+ }
4257
+ /**
4258
+ * The fields whose symbol areas this read will actually read.
4259
+ *
4260
+ * The selection, less anything already decoded and kept. `_fieldsHeldAfter` answers what the read will
4261
+ * be *holding*, which is the right figure for the heap; this is the right one for the bytes it buffers
4262
+ * while parsing and for the I/O it reports, because a cached column's area is left out of the plan
4263
+ * entirely and never read.
4264
+ *
4265
+ * Sized by the wrong one of the two, a warm page was charged external bytes for buffers it never
4266
+ * allocates - and external bytes bind against a container limit, so a page that fits could be refused -
4267
+ * and `estimate.readBytes` claimed I/O it does not perform: on four columns with three cached it
4268
+ * reported 3,155,600 bytes for a read of 788,930.
4269
+ *
4270
+ * @param {Array<any>} selected The fields this read selects.
4271
+ * @return {Array<any>} The fields whose areas will be read.
4272
+ * @private
4273
+ */
4274
+ _fieldsReadBy(selected) {
4275
+ if (this._symbolCache === null || this._symbolCache.size === 0) {
4276
+ return selected;
4277
+ }
4278
+ return selected.filter(
4279
+ (field) => this._symbolCache !== null && !this._symbolCache.has(field["FieldName"])
4280
+ );
4281
+ }
4282
+ /**
4283
+ * Keeps what this reader decodes, so that a later read of the same file does not decode it again.
4284
+ *
4285
+ * For a caller reading one file many times over - a `QvdFile` and its pages - and off by default,
4286
+ * because every other entry point is one read and would only be holding values nobody will ask for
4287
+ * again. Decoding the symbols is 72% of a page of a hundred rows from a 300,000-row file; the rest is
4288
+ * the open, the header, the records and the rows.
4289
+ *
4290
+ * It turns the two-pass symbol path off with it. That path decodes only the symbols a window's rows
4291
+ * use, which is right for one read and wrong for a cache: a later page asking for a row that uses a
4292
+ * skipped symbol would read `undefined` where the value is. So a cached field is always a whole
4293
+ * field, walked and checked against its `NoOfSymbols` like any other.
4294
+ *
4295
+ * @return {void}
4296
+ */
4297
+ beginPaging() {
4298
+ this._symbolCache = /* @__PURE__ */ new Map();
4299
+ }
4300
+ /**
4301
+ * Reads with the fields the caller names for this read alone, rather than the reader's own.
4302
+ *
4303
+ * A `QvdFile` is opened once and its pages may each name a projection, so the selection cannot be
4304
+ * fixed at construction as it is for every other entry point.
4305
+ *
4306
+ * It holds until the next call replaces it rather than being cleared by the read, so **every caller
4307
+ * sets it before every read**, passing the file's own fields where the page named none. A caller that
4308
+ * relied on it being empty would instead get the projection of whatever ran last: that is what made a
4309
+ * `check()` naming no fields answer for the previous page's columns.
4310
+ *
4311
+ * @param {Array<string>|null|undefined} fields The fields, or undefined to use the reader's own.
4312
+ * @return {void}
4313
+ */
4314
+ selectForNextRead(fields) {
4315
+ if (fields !== void 0) {
4316
+ this._requestedFields = fields;
4317
+ }
4318
+ }
4319
+ /**
4320
+ * Watches the next read with the caller's `onProgress` and `signal`, rather than the reader's own.
4321
+ *
4322
+ * Both belong to one call, and a reader is told them when it is built - so a `QvdFile` page that named
4323
+ * either used to get a reader of its own. That made passing a progress callback change what the read
4324
+ * did rather than only observing it: a fresh reader is not paging, so it took the two-pass symbol
4325
+ * path, reported a different `loadStats.symbolFiltering`, and cached nothing. An observer must not
4326
+ * change what it observes, and a caller must not have to choose between cancelling a page and paging
4327
+ * cheaply.
4328
+ *
4329
+ * Like `selectForNextRead`, it holds until the next call replaces it rather than being cleared by the
4330
+ * read, so a caller that sets it for one page and not the next is still watched on the next - pass the
4331
+ * file's own watchers explicitly, as `QvdFile._page` does, rather than leaving them out.
4332
+ *
4333
+ * @param {{onProgress?: Function, signal?: AbortSignal}} [watchers] What this read is watched with.
4334
+ * @return {void}
4335
+ */
4336
+ observeNextRead({ onProgress, signal } = {}) {
4337
+ validateWatchers(onProgress, signal, this._path);
4338
+ this._onProgress = onProgress;
4339
+ this._signal = signal;
4340
+ }
4341
+ /**
4342
+ * Whether the symbol-usage pass runs for this read, cache and all.
4343
+ *
4344
+ * `_analysisWouldRun` answers whether the window wants the pass; a paging read never takes it, because
4345
+ * a column decoded in part cannot be kept. Asked in one place because it was asked in two and they
4346
+ * disagreed: the pass was gated on the cache while the memory charge and `estimate.readBytes` were
4347
+ * not, so every page of a file above the threshold was charged a slice of records it never buffered
4348
+ * and reported twice the bytes it read.
4349
+ *
4350
+ * @param {QvdRowWindow} window The window as the caller spelled it.
4351
+ * @param {{offset: number, limit: number}} resolved Where it lands in this file.
4352
+ * @param {number} totalRows Rows the file declares.
4353
+ * @param {number} symbolTableLength The symbol table's declared length.
4354
+ * @return {boolean} Whether the pass will run.
4355
+ * @private
4356
+ */
4357
+ _analysisAhead(window, resolved, totalRows, symbolTableLength) {
4358
+ return this._symbolCache === null && this._analysisWouldRun(window, resolved, totalRows, symbolTableLength);
4359
+ }
4360
+ /**
4361
+ * Empties the cache when the header in front of us is not the one it was decoded from.
4362
+ *
4363
+ * The fingerprint is what a rewrite moves: the file's identity on disk - device, inode, modification
4364
+ * time and size - and then the header numbers, down to each cached field's own offset, length and
4365
+ * symbol count.
4366
+ *
4367
+ * The header numbers alone were not enough, and the gap is not exotic. `QvdFileWriter` carries
4368
+ * `CreateUtcTime` over from the metadata it is handed, so reading a QVD, changing one text to another
4369
+ * of the same byte length and writing it back leaves `CreateUtcTime`, `NoOfRecords`, `Offset` and
4370
+ * every field's `Offset`, `Length` and `NoOfSymbols` exactly as they were - a different file the
4371
+ * fingerprint could not tell from the first. The filesystem sees it either way: an atomic write
4372
+ * renames a new file into place, which changes the inode, and an in-place one moves `mtimeMs`.
4373
+ *
4374
+ * @return {void}
4375
+ * @private
4376
+ */
4377
+ _forgetCacheIfFileChanged() {
4378
+ if (this._symbolCache === null || this._symbolCache.size === 0) {
4379
+ return;
4380
+ }
4381
+ if (this._cachedFor !== this._fileFingerprint()) {
4382
+ this._symbolCache = /* @__PURE__ */ new Map();
4383
+ this._cachedFor = null;
4384
+ }
4385
+ }
4386
+ /**
4387
+ * What identifies the file this reader's cache was decoded from.
4388
+ *
4389
+ * @return {string} The fingerprint.
4390
+ * @private
4391
+ */
4392
+ _fileFingerprint() {
4393
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
4394
+ const header = this._header["QvdTableHeader"];
4395
+ return [
4396
+ // First, because it is the only part that moves when a rewrite preserves the header's numbers.
4397
+ this._fileIdentity,
4398
+ header["CreateUtcTime"],
4399
+ header["NoOfRecords"],
4400
+ header["Offset"],
4401
+ ...this._allFields.map(
4402
+ (field) => `${field["FieldName"]}:${field["Offset"]}:${field["Length"]}:${field["NoOfSymbols"]}`
4403
+ )
4404
+ ].join("|");
4405
+ }
4406
+ /**
4407
+ * Drops everything this reader has decoded, so that nothing outlives the caller that wanted it.
4408
+ *
4409
+ * @return {void}
4410
+ */
4411
+ endPaging() {
4412
+ this._symbolCache = null;
4413
+ this._cachedFor = null;
4414
+ }
4017
4415
  /**
4018
4416
  * The symbol table's length, as much of it as the file holds: what the header declares, cut short where
4019
4417
  * the file ends. Known before a byte of the table is read, so everything that can refuse the table is
@@ -4042,7 +4440,13 @@ var init_QvdFileReader = __esm({
4042
4440
  * order, so ranges that touch are merged: a read of every field is one range, and so is a read of fields
4043
4441
  * that happen to be neighbours. A read of one field of twenty reads that field's area alone.
4044
4442
  *
4045
- * Built once per read, from the fields the read selected, and each field's metadata is checked as it is
4443
+ * A field whose symbols this reader already holds is left out, because its bytes are not wanted: the
4444
+ * ranges are what gets read, and including a cached field's span had a page read every byte of every
4445
+ * column it named, cached or not. Measured on four columns of 20,000 distinct texts, a page naming all
4446
+ * four with three of them cached read all four columns' bytes - 1,155,600 of them, where 288,930 were
4447
+ * needed. The decode was saved and the I/O was not, which on the files #122 is about is the whole cost.
4448
+ *
4449
+ * Built once per read, from the fields the read must read, and each field's metadata is checked as it is
4046
4450
  * added - a range is arithmetic on `Offset` and `Length`, and those have to be inside the table first.
4047
4451
  * `_parseSymbolTable` checks every field of the file, selected or not, before it parses any.
4048
4452
  *
@@ -4057,7 +4461,10 @@ var init_QvdFileReader = __esm({
4057
4461
  }
4058
4462
  assert4(this._selectedFields, "The QVD file fields have not been resolved before their symbols were read.");
4059
4463
  const tableLength = this._symbolTableLength();
4060
- const areas = this._selectedFields.map((field) => {
4464
+ const toRead = this._selectedFields.filter(
4465
+ (field) => this._symbolCache === null || !this._symbolCache.has(field["FieldName"])
4466
+ );
4467
+ const areas = toRead.map((field) => {
4061
4468
  validateFieldMetadata(field, tableLength, this._path);
4062
4469
  const start = headerInteger(field["Offset"]);
4063
4470
  return { field, start, end: start + headerInteger(field["Length"]) };
@@ -4142,7 +4549,7 @@ var init_QvdFileReader = __esm({
4142
4549
  return;
4143
4550
  }
4144
4551
  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))));
4552
+ const sliceRows = this._sliceRowsFor(rowCount, recordSize);
4146
4553
  const slice = Buffer.alloc(sliceRows * recordSize);
4147
4554
  const requiredBytes = this._indexTableOffset + (firstRow + rowCount) * recordSize;
4148
4555
  for (let done = 0; done < rowCount; done += sliceRows) {
@@ -4379,9 +4786,11 @@ var init_QvdFileReader = __esm({
4379
4786
  }
4380
4787
  const allFields = this._allFields;
4381
4788
  const fields = this._selectedFields;
4789
+ this._forgetCacheIfFileChanged();
4382
4790
  const symbolTableSize = this._symbolTableLength();
4383
4791
  const plan = this._symbolAreaPlan();
4384
- const symbolBytes = plan.ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
4792
+ const readSymbolBytes = plan.ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
4793
+ const symbolBytes = readSymbolBytes + symbolBytesOf(this._fieldsHeldAfter(fields, allFields).slice(fields.length), symbolTableSize);
4385
4794
  const totalRows = headerInteger(this._header["QvdTableHeader"]["NoOfRecords"]);
4386
4795
  const recordSize = headerInteger(this._header["QvdTableHeader"]["RecordByteSize"]);
4387
4796
  validateSymbolTableSize(symbolBytes, this._path, totalRows);
@@ -4395,10 +4804,21 @@ var init_QvdFileReader = __esm({
4395
4804
  fields.length,
4396
4805
  this._materialisesRows,
4397
4806
  liveRows,
4398
- this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false)
4807
+ this._bytesHeld(readSymbolBytes, rowsToLoad, recordSize, liveRows, false),
4808
+ // `symbolsToKeep` is non-null exactly when the symbol-usage pass has run, and a pass that has
4809
+ // run has read the window's records once already - so the read's total is two passes over them.
4810
+ readSymbolBytes + readPasses(symbolsToKeep !== null) * rowsToLoad * recordSize,
4811
+ this._symbolCache !== null
4399
4812
  );
4400
4813
  }
4401
- warnLargeSymbolTable(symbolBytes, rowsToLoad, totalRows, fields.length, this._materialisesRows);
4814
+ warnLargeSymbolTable(
4815
+ symbolBytes,
4816
+ rowsToLoad,
4817
+ totalRows,
4818
+ fields.length,
4819
+ this._materialisesRows,
4820
+ this._symbolCache !== null
4821
+ );
4402
4822
  for (const field of allFields) {
4403
4823
  validateFieldMetadata(field, symbolTableSize, this._path);
4404
4824
  }
@@ -4406,24 +4826,36 @@ var init_QvdFileReader = __esm({
4406
4826
  const symbolTable = [];
4407
4827
  for (const [position, field] of fields.entries()) {
4408
4828
  this._throwIfAborted();
4829
+ const cached = this._symbolCache?.get(field["FieldName"]);
4830
+ if (cached) {
4831
+ symbolTable.push(cached);
4832
+ this._emitProgress("symbol-table", position + 1, fields.length);
4833
+ continue;
4834
+ }
4409
4835
  const area = await this._symbolAreaOf(field);
4410
- symbolTable.push(
4411
- parseFieldSymbols(
4412
- area.buffer,
4413
- area.start,
4414
- area.end,
4415
- // Checked against the symbols the area holds, which is the one check that sees a terminator
4416
- // damaged in the middle of it (#124).
4417
- headerInteger(field["NoOfSymbols"]),
4418
- // By position, matching how `_analyzeIndexTableSymbolUsage` built it. Both walk
4419
- // `this._selectedFields`, so position is the one key that cannot collide.
4420
- symbolsToKeep ? symbolsToKeep[position] : null,
4421
- field["FieldName"],
4422
- this._path,
4423
- void 0,
4424
- area.base
4425
- )
4836
+ const parsed = parseFieldSymbols(
4837
+ area.buffer,
4838
+ area.start,
4839
+ area.end,
4840
+ // Checked against the symbols the area holds, which is the one check that sees a terminator
4841
+ // damaged in the middle of it (#124). A cached field was checked when it was decoded, which is
4842
+ // why the cache may only hold a field a full walk produced.
4843
+ headerInteger(field["NoOfSymbols"]),
4844
+ // By position, matching how `_analyzeIndexTableSymbolUsage` built it. Both walk
4845
+ // `this._selectedFields`, so position is the one key that cannot collide.
4846
+ symbolsToKeep ? symbolsToKeep[position] : null,
4847
+ field["FieldName"],
4848
+ this._path,
4849
+ void 0,
4850
+ area.base
4426
4851
  );
4852
+ symbolTable.push(parsed);
4853
+ if (this._symbolCache && symbolsToKeep === null) {
4854
+ if (this._symbolCache.size === 0) {
4855
+ this._cachedFor = this._fileFingerprint();
4856
+ }
4857
+ this._symbolCache.set(field["FieldName"], parsed);
4858
+ }
4427
4859
  this._releaseSymbolArea(field);
4428
4860
  this._emitProgress("symbol-table", position + 1, fields.length);
4429
4861
  }
@@ -4518,28 +4950,173 @@ var init_QvdFileReader = __esm({
4518
4950
  await this._parseHeader();
4519
4951
  this._emitProgress("header", 1, 1);
4520
4952
  this._throwIfAborted();
4521
- assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
4522
- const header = this._header["QvdTableHeader"];
4523
- const columns = this._allFields.map((field) => field["FieldName"]);
4524
- const rowCount = headerInteger(header["NoOfRecords"]);
4525
- validateRecordCount(rowCount, this._path, "readMetadata");
4526
- const shape = new QvdDataFrame([], columns, header, {
4527
- symbolTableBytes: headerInteger(header["Offset"]),
4528
- totalRows: rowCount,
4529
- rowsLoaded: 0,
4530
- symbolFiltering: false,
4531
- symbolsKept: null
4532
- });
4533
- return {
4534
- columns,
4535
- rowCount,
4536
- columnCount: columns.length,
4537
- fields: columns.map((name) => shape.getFieldMetadata(name)),
4538
- fileMetadata: shape.fileMetadata,
4539
- metadata: header
4540
- };
4953
+ return this.describeParsed();
4541
4954
  });
4542
4955
  }
4956
+ /**
4957
+ * The schema and header of the file this reader has parsed, as `readMetadata()` reports them.
4958
+ *
4959
+ * Built from the parsed header and nothing else, so a caller holding a header - a `QvdFile` - can have
4960
+ * it without reading the file a second time.
4961
+ *
4962
+ * @return {any} The metadata.
4963
+ */
4964
+ describeParsed() {
4965
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
4966
+ const header = this._header["QvdTableHeader"];
4967
+ const columns = this._allFields.map((field) => field["FieldName"]);
4968
+ const rowCount = headerInteger(header["NoOfRecords"]);
4969
+ validateRecordCount(rowCount, this._path, "readMetadata");
4970
+ const shape = new QvdDataFrame([], columns, header, {
4971
+ symbolTableBytes: headerInteger(header["Offset"]),
4972
+ totalRows: rowCount,
4973
+ rowsLoaded: 0,
4974
+ symbolFiltering: false,
4975
+ symbolsKept: null
4976
+ });
4977
+ return {
4978
+ columns,
4979
+ rowCount,
4980
+ columnCount: columns.length,
4981
+ fields: columns.map((name) => shape.getFieldMetadata(name)),
4982
+ fileMetadata: shape.fileMetadata,
4983
+ metadata: header
4984
+ };
4985
+ }
4986
+ /**
4987
+ * What a read of this file would cost, and whether it fits, without reading it.
4988
+ *
4989
+ * Reads the header and the file's size and nothing else, at the constant cost of `loadMetadata()`,
4990
+ * then asks the same question a read asks before it allocates anything - through the same function,
4991
+ * from the same numbers. That is the whole point: an answer computed a second way would be a second
4992
+ * opinion, and a read this approves would still be refused.
4993
+ *
4994
+ * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [rawWindow]
4995
+ * The rows the read would cover, spelled any of the ways a read accepts.
4996
+ * @param {{chunkSize?: number|null}} [options] `chunkSize` when the read would be an `iterate()`,
4997
+ * which holds two chunks of rows rather than the window.
4998
+ * @return {Promise<any>} The answer - see `checkMemory`.
4999
+ */
5000
+ async checkRead(rawWindow, { chunkSize = null } = {}) {
5001
+ const window = normaliseWindow(rawWindow, this._path);
5002
+ return await this._closingAfter(async () => {
5003
+ await this._parseHeaderChecked();
5004
+ return this.checkParsed(window, { chunkSize });
5005
+ });
5006
+ }
5007
+ /**
5008
+ * Reads this file's header, and nothing else, leaving it parsed on the reader.
5009
+ *
5010
+ * What `checkRead` and `QvdFile` both start with: the second asks many questions of one header, so the
5011
+ * read that produces it is separate from the questions. Every check a read makes before it trusts the
5012
+ * header's numbers is made here, so that nothing downstream has to wonder whether they hold.
5013
+ *
5014
+ * @return {Promise<void>} When the header is parsed and checked.
5015
+ */
5016
+ async parseHeaderOnly() {
5017
+ return await this._closingAfter(async () => await this._parseHeaderChecked());
5018
+ }
5019
+ /**
5020
+ * `parseHeaderOnly`'s body, for a caller already inside a read session - `checkRead` is one.
5021
+ *
5022
+ * @return {Promise<void>} When the header is parsed and checked.
5023
+ * @private
5024
+ */
5025
+ async _parseHeaderChecked() {
5026
+ await this._readData({ offset: 0, limit: null }, true);
5027
+ this._emitProgress("header", 0, 1);
5028
+ await this._parseHeader();
5029
+ this._emitProgress("header", 1, 1);
5030
+ this._throwIfAborted();
5031
+ assert4(
5032
+ this._header && this._selectedFields && this._allFields && this._symbolTableOffset !== null,
5033
+ "The QVD file header has not been parsed."
5034
+ );
5035
+ const header = this._header["QvdTableHeader"];
5036
+ const totalRows = headerInteger(header["NoOfRecords"]);
5037
+ const recordSize = headerInteger(header["RecordByteSize"]);
5038
+ const symbolTableLength = headerInteger(header["Offset"]);
5039
+ validateRecordSize(recordSize, this._path, "checkRead");
5040
+ validateRecordCount(totalRows, this._path, "checkRead");
5041
+ if (!this._headerMatchesFile) {
5042
+ throw new QvdCorruptedError("The file is shorter than its header claims.", {
5043
+ file: this._path,
5044
+ fileSize: this._fileSize,
5045
+ requiredBytes: this._symbolTableOffset + symbolTableLength + totalRows * recordSize,
5046
+ stage: "checkRead"
5047
+ });
5048
+ }
5049
+ const tableLength = this._symbolTableLength();
5050
+ for (const field of this._allFields) {
5051
+ validateFieldMetadata(field, tableLength, this._path);
5052
+ validateFieldBitMetadata(field, recordSize, this._path);
5053
+ }
5054
+ validateSymbolAreas(this._allFields, this._path);
5055
+ }
5056
+ /**
5057
+ * What a read of the parsed header's file would cost, with no I/O at all.
5058
+ *
5059
+ * Separate from `checkRead` because a `QvdFile` asks this of one header many times - once per page a
5060
+ * viewer scrolls to - and the header is already in hand. `parseHeaderOnly` has to have run.
5061
+ *
5062
+ * @param {QvdRowWindow} window The rows the read would cover, normalised.
5063
+ * @param {{chunkSize?: number|null, fields?: Array<string>|null, materialisesRows?: boolean}} [options]
5064
+ * `chunkSize` for an `iterate()`; `fields` and `materialisesRows` to ask about a read other than the
5065
+ * one this reader was built for, which is what a `QvdFile` does per call.
5066
+ * @return {any} The answer - see `checkMemory`.
5067
+ */
5068
+ checkParsed(window, { chunkSize = null, fields = void 0, materialisesRows = void 0 } = {}) {
5069
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
5070
+ const header = this._header["QvdTableHeader"];
5071
+ const totalRows = headerInteger(header["NoOfRecords"]);
5072
+ const recordSize = headerInteger(header["RecordByteSize"]);
5073
+ const symbolTableLength = headerInteger(header["Offset"]);
5074
+ const builds = materialisesRows === void 0 ? this._materialisesRows : materialisesRows;
5075
+ const selected = selectFields(this._allFields, fields === void 0 ? this._requestedFields : fields, this._path);
5076
+ const resolved = resolveWindow(window, totalRows);
5077
+ const windowRows = resolved.limit;
5078
+ const liveRows = chunkSize === null ? null : { rows: chunkSize * 2, perChunk: 2 };
5079
+ const analysisAhead = this._analysisAhead(window, resolved, totalRows, symbolTableLength);
5080
+ const measured = getMemoryBudget();
5081
+ const ask = /* @__PURE__ */ __name((asked, rows) => {
5082
+ const bytes = symbolBytesOf(this._fieldsHeldAfter(asked, this._allFields), symbolTableLength);
5083
+ const read = symbolBytesOf(this._fieldsReadBy(asked), symbolTableLength);
5084
+ return checkMemory({
5085
+ measured,
5086
+ symbolTableSize: bytes,
5087
+ maxRows: rows,
5088
+ totalRows,
5089
+ safetyFactor: this._memorySafetyFactor,
5090
+ columnCount: asked.length,
5091
+ materialisesRows: builds,
5092
+ live: liveRows,
5093
+ // A paging read keeps whole columns, so it is charged for whole columns - see `estimateMemoryUsage`.
5094
+ wholeSymbols: this._symbolCache !== null,
5095
+ bytesHeld: this._bytesHeld(read, rows, recordSize, liveRows, analysisAhead),
5096
+ // What it reads from the file, which is not what it holds: the symbol areas it has still to read,
5097
+ // and every record the window covers, read a slice at a time and not kept - twice over where the
5098
+ // symbol-usage pass will run, since it reads them before the decode reads them again.
5099
+ readBytes: read + readPasses(analysisAhead) * windowRows * recordSize
5100
+ });
5101
+ }, "ask");
5102
+ const answer = ask(selected, windowRows);
5103
+ if (!answer.fits && selected.length > 1) {
5104
+ const bySize = [...selected].sort(
5105
+ (a, b) => headerInteger(a["Length"]) - headerInteger(b["Length"])
5106
+ );
5107
+ for (let take = selected.length - 1; take >= 1; take -= 1) {
5108
+ const fewer = bySize.slice(0, take);
5109
+ if (ask(fewer, windowRows).fits) {
5110
+ answer.suggestions.push({
5111
+ option: "fields",
5112
+ value: fewer.map((field) => field["FieldName"])
5113
+ });
5114
+ break;
5115
+ }
5116
+ }
5117
+ }
5118
+ return answer;
5119
+ }
4543
5120
  /**
4544
5121
  * Loads the QVD file into memory and parses it.
4545
5122
  *
@@ -4629,13 +5206,7 @@ var init_QvdFileReader = __esm({
4629
5206
  * @return {AsyncGenerator<QvdDataFrame>} The chunks, in order.
4630
5207
  */
4631
5208
  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
- }
5209
+ requireChunkSize(chunkSize, this._path);
4639
5210
  const liveRows = { rows: chunkSize * 2, perChunk: 2 };
4640
5211
  const rows = normaliseWindow(window, this._path);
4641
5212
  this._startRead();
@@ -4710,7 +5281,7 @@ var init_QvdFileReader = __esm({
4710
5281
  const rowsAvailable = resolved.limit;
4711
5282
  let symbolsToKeep = null;
4712
5283
  let symbolsKept = null;
4713
- if (this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)) {
5284
+ if (this._analysisAhead(window, resolved, totalRows, symbolTableLength)) {
4714
5285
  symbolsToKeep = await this._analyzeIndexTableSymbolUsage({ offset: resolved.offset, limit: rowsAvailable });
4715
5286
  symbolsKept = symbolsToKeep.reduce((sum, set) => sum + set.size, 0);
4716
5287
  }
@@ -4802,6 +5373,224 @@ var init_QvdFileReader = __esm({
4802
5373
  }
4803
5374
  });
4804
5375
 
5376
+ // src/QvdFile.js
5377
+ var QvdFile_exports = {};
5378
+ __export(QvdFile_exports, {
5379
+ QvdFile: () => QvdFile
5380
+ });
5381
+ var QvdFile;
5382
+ var init_QvdFile = __esm({
5383
+ "src/QvdFile.js"() {
5384
+ init_QvdErrors();
5385
+ init_readOptions();
5386
+ QvdFile = class {
5387
+ static {
5388
+ __name(this, "QvdFile");
5389
+ }
5390
+ /**
5391
+ * Not called directly - `QvdDataFrame.open()` is the way in, because a `QvdFile` is only ever a file
5392
+ * whose header has been read, and a constructor cannot wait for that.
5393
+ *
5394
+ * @param {any} reader The reader holding the parsed header.
5395
+ * @param {any} metadata What `readMetadata()` returns for this file.
5396
+ * @param {any} options The options the file was opened with.
5397
+ * @private
5398
+ */
5399
+ constructor(reader, metadata, options) {
5400
+ this._reader = reader;
5401
+ this._metadata = metadata;
5402
+ this._options = options;
5403
+ this._closed = false;
5404
+ this._tail = Promise.resolve();
5405
+ this._readers = { rows: null, columns: null };
5406
+ }
5407
+ /**
5408
+ * The file's header and schema, as `QvdDataFrame.readMetadata()` returns them.
5409
+ *
5410
+ * Read when the file was opened, so this costs nothing and cannot fail.
5411
+ *
5412
+ * @return {any} The metadata.
5413
+ */
5414
+ get metadata() {
5415
+ return this._metadata;
5416
+ }
5417
+ /**
5418
+ * Whether `close()` has been called.
5419
+ *
5420
+ * @return {boolean} True once it has.
5421
+ */
5422
+ get closed() {
5423
+ return this._closed;
5424
+ }
5425
+ /**
5426
+ * What a read of this file would cost, and whether it fits - with no I/O at all.
5427
+ *
5428
+ * The same answer `QvdDataFrame.checkRead()` gives, from the header this file already holds, so a
5429
+ * viewer can size a page before asking for it without touching the disk.
5430
+ *
5431
+ * @param {{offset?: number, limit?: number|null, maxRows?: number|null, fields?: Array<string>|null,
5432
+ * as?: 'rows'|'columns', chunkSize?: number|null}} [options] The read being asked about - the same
5433
+ * bag `rows()` takes, plus `as` and `chunkSize` to say which shape of read it is.
5434
+ * @return {any} The answer - `fits`, `reason`, `estimate`, `budget`, `exact`, `suggestions`.
5435
+ * @throws {QvdValidationError} If the file is closed, or an option's value is not valid.
5436
+ */
5437
+ check(options = {}) {
5438
+ this._refuseWhenClosed("check");
5439
+ const { as = "rows", chunkSize = null } = options;
5440
+ if (as !== "rows" && as !== "columns") {
5441
+ throw new QvdValidationError("as must be 'rows' or 'columns'", {
5442
+ provided: as,
5443
+ reason: "option",
5444
+ option: "as",
5445
+ value: as,
5446
+ file: this._options.path
5447
+ });
5448
+ }
5449
+ if (chunkSize !== null) {
5450
+ requireChunkSize(chunkSize, this._options.path);
5451
+ }
5452
+ const reader = this._readers[as] ?? this._reader;
5453
+ return reader.checkParsed(normaliseWindow(windowFrom(options), this._options.path), {
5454
+ chunkSize,
5455
+ // Resolved here rather than left to the reader, exactly as `_page` resolves it. A warm reader is
5456
+ // still holding the last page's selection, and `checkParsed` falls back to it - so a `check()`
5457
+ // naming no fields answered for whatever the previous page happened to name. On a four-column
5458
+ // file after a page naming one of them, it reported 27,490 bytes for a read that costs 108,160:
5459
+ // understating, which is the direction that approves a read the read then refuses.
5460
+ fields: options.fields === void 0 ? this._options.fields ?? null : options.fields,
5461
+ materialisesRows: as === "rows"
5462
+ });
5463
+ }
5464
+ /**
5465
+ * Reads a page of rows.
5466
+ *
5467
+ * @param {{offset?: number, limit?: number|null, maxRows?: number|null, fields?: Array<string>|null,
5468
+ * onProgress?: Function, signal?: AbortSignal}} [options] The page, and how to read it. One bag,
5469
+ * as every other entry point takes: `offset` and `limit` say which rows, `fields` names a projection
5470
+ * for this page alone, and anything left out falls back to what the file was opened with.
5471
+ * @return {Promise<any>} The page, as a `QvdDataFrame`.
5472
+ * @throws {QvdValidationError} If the file is closed.
5473
+ */
5474
+ async rows(options = {}) {
5475
+ this._refuseWhenClosed("rows");
5476
+ return await this._serialised(async () => await this._page(options, true, (reader, window) => reader.load(window)));
5477
+ }
5478
+ /**
5479
+ * Reads a page as columns, building no rows.
5480
+ *
5481
+ * @param {{offset?: number, limit?: number|null, maxRows?: number|null, fields?: Array<string>|null,
5482
+ * onProgress?: Function, signal?: AbortSignal}} [options] The page, as `rows()` takes it.
5483
+ * @return {Promise<any>} The page, as a `QvdColumnTable`.
5484
+ * @throws {QvdValidationError} If the file is closed.
5485
+ */
5486
+ async columns(options = {}) {
5487
+ this._refuseWhenClosed("columns");
5488
+ return await this._serialised(
5489
+ async () => await this._page(options, false, (reader, window) => reader.loadColumnar(window))
5490
+ );
5491
+ }
5492
+ /**
5493
+ * Closes the file.
5494
+ *
5495
+ * Every call after it is refused with `reason: 'closed'`. Calling it twice is not an error: a
5496
+ * `finally` that closes and an `await using` that closes are both right, and both may run.
5497
+ *
5498
+ * @return {Promise<void>} When the pages already in flight have finished.
5499
+ */
5500
+ async close() {
5501
+ if (this._closed) {
5502
+ return;
5503
+ }
5504
+ this._closed = true;
5505
+ await this._tail;
5506
+ for (const reader of Object.values(this._readers)) {
5507
+ reader?.endPaging();
5508
+ }
5509
+ this._readers = { rows: null, columns: null };
5510
+ this._reader.endPaging();
5511
+ this._reader = null;
5512
+ }
5513
+ /**
5514
+ * `await using` support, where the runtime has it.
5515
+ *
5516
+ * @return {Promise<void>} When closed.
5517
+ */
5518
+ async [Symbol.asyncDispose]() {
5519
+ await this.close();
5520
+ }
5521
+ /**
5522
+ * Refuses a call on a closed file, in the vocabulary the rest of the API uses.
5523
+ *
5524
+ * @param {string} call The method the caller reached for, for the error.
5525
+ * @private
5526
+ */
5527
+ _refuseWhenClosed(call) {
5528
+ if (this._closed) {
5529
+ throw new QvdValidationError("The file is closed: open it again to read from it", {
5530
+ reason: "closed",
5531
+ call,
5532
+ file: this._options.path
5533
+ });
5534
+ }
5535
+ }
5536
+ /**
5537
+ * Runs `work` after everything asked for before it, and before everything asked for after.
5538
+ *
5539
+ * @param {() => Promise<any>} work The page to read.
5540
+ * @return {Promise<any>} Its result.
5541
+ * @private
5542
+ */
5543
+ async _serialised(work) {
5544
+ const run = this._tail.then(work, work);
5545
+ this._tail = run.then(
5546
+ () => void 0,
5547
+ () => void 0
5548
+ );
5549
+ return await run;
5550
+ }
5551
+ /**
5552
+ * Reads one page, through the reader that keeps what the pages before it decoded.
5553
+ *
5554
+ * One reader for every page rather than one per page, which is what makes the symbol cache possible:
5555
+ * decoding the symbols is 72% of a page of a hundred rows from a 300,000-row file, and a reader built
5556
+ * fresh each time did all of it again. Two readers, because a columnar page builds no rows and a row
5557
+ * page does, and `materialisesRows` is fixed when a reader is constructed - so each shape keeps its
5558
+ * own, and its own cache.
5559
+ *
5560
+ * Options that belong to one call rather than to the file - the fields this page alone wants, and the
5561
+ * `onProgress` and `signal` watching it - are told to that shared reader for the next read and no
5562
+ * further. A page naming one of them used to build a reader of its own instead, which quietly turned
5563
+ * the cache off and the two-pass symbol path on: watching a page changed what the page did.
5564
+ *
5565
+ * @param {any} options What the call passed.
5566
+ * @param {boolean} builds Whether the page materialises rows.
5567
+ * @param {(reader: any, window: any) => Promise<any>} read The read to make.
5568
+ * @return {Promise<any>} The page.
5569
+ * @private
5570
+ */
5571
+ async _page(options, builds, read) {
5572
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5573
+ const kept = builds ? "rows" : "columns";
5574
+ if (!this._readers[kept]) {
5575
+ const reader2 = new QvdFileReader2(this._options.path, {
5576
+ ...readerOptionsFrom(this._options),
5577
+ materialisesRows: builds
5578
+ });
5579
+ reader2.beginPaging();
5580
+ this._readers[kept] = reader2;
5581
+ }
5582
+ const reader = this._readers[kept];
5583
+ reader.selectForNextRead(options.fields === void 0 ? this._options.fields ?? null : options.fields);
5584
+ reader.observeNextRead({
5585
+ onProgress: options.onProgress ?? this._options.onProgress,
5586
+ signal: options.signal ?? this._options.signal
5587
+ });
5588
+ return await read(reader, windowFrom(options));
5589
+ }
5590
+ };
5591
+ }
5592
+ });
5593
+
4805
5594
  // src/QvdDataFrame.js
4806
5595
  function defaultFieldHeader(fieldName) {
4807
5596
  return {
@@ -5550,6 +6339,111 @@ var init_QvdDataFrame = __esm({
5550
6339
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5551
6340
  return await new QvdFileReader2(path5, metadataOptionsFrom(options)).loadMetadata();
5552
6341
  }
6342
+ /**
6343
+ * Answers what a read would cost, and whether it fits, without doing it.
6344
+ *
6345
+ * Takes the options `fromQvd()` takes, plus `as` and `chunkSize` to say which read is being asked
6346
+ * about. Reads the header and the file's size and nothing else, at a cost that does not grow with
6347
+ * the file.
6348
+ *
6349
+ * The answer comes from the same function a read consults before it allocates anything, from the
6350
+ * same numbers, so **a read this approves is not refused later for memory** - and every suggestion
6351
+ * it carries has been read back through the check, so following one gives a read that fits.
6352
+ *
6353
+ * It answers about resources, so it answers only for a header it can trust. A header whose numbers
6354
+ * are not usable, or that claims more than the file holds, is refused as a `QvdCorruptedError` rather
6355
+ * than answered: sizing a read from numbers the file contradicts produced a memory verdict about a
6356
+ * file whose real problem was structural, and it was wrong in both directions - approving a read the
6357
+ * library then refused, and refusing another with advice that was refused too.
6358
+ *
6359
+ * That covers everything a reader can tell from the header: a field area past the end of the symbol
6360
+ * table, two fields claiming one area, a `Bias` that is neither 0 nor -2, a `BitWidth` past 31. Damage
6361
+ * that is not in the header - a value or an index the file has spoiled - is still found only by
6362
+ * reading, and still refused as a `QvdCorruptedError` after this has said the read fits.
6363
+ *
6364
+ * ```js
6365
+ * const answer = await QvdDataFrame.checkRead('huge.qvd', {as: 'columns', fields: ['Amount']});
6366
+ *
6367
+ * if (!answer.fits) {
6368
+ * console.log(answer.reason); // 'memory'
6369
+ * console.log(answer.suggestions); // [{option: 'limit', value: 1250000}, ...]
6370
+ * }
6371
+ * ```
6372
+ *
6373
+ * @param {string} path The QVD file.
6374
+ * @param {object} [options] What `fromQvd()` takes, plus the two below.
6375
+ * @param {'rows'|'columns'} [options.as='rows'] Which read is being asked about: `rows` builds row
6376
+ * arrays and `columns` does not, which is most of what a read costs.
6377
+ * @param {number|null} [options.chunkSize=null] The chunk an `iterate()` would use, which holds two
6378
+ * chunks of rows rather than the whole window.
6379
+ * @return {Promise<any>} The answer: `fits`, `reason` when it does not, `estimate`, `budget`,
6380
+ * `exact` and `suggestions`.
6381
+ * @throws {QvdValidationError} If an option's value is not valid, with `context.reason` of `option`.
6382
+ * @throws {QvdCorruptedError} If the header cannot be read, its numbers are not usable, or it claims
6383
+ * more than the file holds. The read refuses such a file too, though it may name the fault
6384
+ * differently - it gets there by planning the index table, where this gets there from the size.
6385
+ */
6386
+ static async checkRead(path5, options = {}) {
6387
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
6388
+ const { as = "rows", chunkSize = null } = options;
6389
+ if (as !== "rows" && as !== "columns") {
6390
+ throw new QvdValidationError("as must be 'rows' or 'columns'", {
6391
+ provided: as,
6392
+ reason: "option",
6393
+ option: "as",
6394
+ value: as,
6395
+ file: path5
6396
+ });
6397
+ }
6398
+ if (chunkSize !== null) {
6399
+ requireChunkSize(chunkSize, path5);
6400
+ }
6401
+ const reader = new QvdFileReader2(path5, { ...readerOptionsFrom(options), materialisesRows: as === "rows" });
6402
+ return await reader.checkRead(windowFrom(options), { chunkSize });
6403
+ }
6404
+ /**
6405
+ * Opens a QVD file for paging, reading its header and nothing else.
6406
+ *
6407
+ * Every other entry point is one read from start to finish. A viewer showing a hundred rows at a time
6408
+ * pays the header again on every page, and `iterate()` goes forwards only - it cannot jump to row five
6409
+ * million and it cannot go back. This holds the header so that `check()` costs nothing and a page can
6410
+ * be asked for by position.
6411
+ *
6412
+ * ```js
6413
+ * const qvd = await QvdDataFrame.open('sales.qvd', {allowedDir: '/data'});
6414
+ *
6415
+ * qvd.metadata; // read once, when it opened
6416
+ * const answer = qvd.check({offset: 0, limit: 100}); // no I/O at all
6417
+ * const page = await qvd.rows({offset: 5_000_000, limit: 100});
6418
+ * const cols = await qvd.columns({offset: 0, limit: 100, fields: ['Amount']});
6419
+ *
6420
+ * await qvd.close();
6421
+ * ```
6422
+ *
6423
+ * The header is read once, and so is each column: a column decoded for one page is kept for the pages
6424
+ * after it, so the first page costs about what a single read costs and the ones after it are cheap.
6425
+ * What a file has decoded is charged to the memory check, so a page is refused rather than the process
6426
+ * aborting, and `close()` releases it - close a file you have finished with.
6427
+ *
6428
+ * Each page still opens the file, and a first touch still decodes a whole column rather than only as
6429
+ * far as the page needs.
6430
+ *
6431
+ * @param {string} path The QVD file.
6432
+ * @param {object} [options] What `fromQvd()` takes - `allowedDir`, `fields`, `duals`,
6433
+ * `coerceNumericStrings`, `memorySafetyFactor` - describing the file and how its values read. A
6434
+ * window means nothing here: pages carry their own.
6435
+ * @return {Promise<import('./QvdFile.js').QvdFile>} The open file.
6436
+ * @throws {QvdValidationError} If an option's value is not valid, with `context.reason` of `option`.
6437
+ * @throws {QvdCorruptedError} If the header cannot be read, or describes a file this is not.
6438
+ */
6439
+ static async open(path5, options = {}) {
6440
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
6441
+ const { QvdFile: QvdFile2 } = await Promise.resolve().then(() => (init_QvdFile(), QvdFile_exports));
6442
+ const reader = new QvdFileReader2(path5, readerOptionsFrom(options));
6443
+ reader.beginPaging();
6444
+ await reader.parseHeaderOnly();
6445
+ return new QvdFile2(reader, reader.describeParsed(), { ...options, path: path5 });
6446
+ }
5553
6447
  /**
5554
6448
  * Constructs a data frame from a dictionary.
5555
6449
  *
@@ -5854,10 +6748,11 @@ __name(dateToQlikSerial, "dateToQlikSerial");
5854
6748
  // src/index.js
5855
6749
  init_QvdDataFrame();
5856
6750
  init_QvdColumnTable();
6751
+ init_QvdFile();
5857
6752
  init_QvdFileReader();
5858
6753
  init_QvdFileWriter();
5859
6754
  init_QvdErrors();
5860
6755
 
5861
- export { QvdColumn, QvdColumnTable, QvdCorruptedError, QvdDataFrame, QvdDual, QvdError, QvdFileReader, QvdFileWriter, QvdIOError, QvdParseError, QvdSecurityError, QvdSymbol, QvdValidationError, dateToQlikSerial, qlikSerialToDate };
6756
+ export { QvdColumn, QvdColumnTable, QvdCorruptedError, QvdDataFrame, QvdDual, QvdError, QvdFile, QvdFileReader, QvdFileWriter, QvdIOError, QvdParseError, QvdSecurityError, QvdSymbol, QvdValidationError, dateToQlikSerial, qlikSerialToDate };
5862
6757
  //# sourceMappingURL=index.js.map
5863
6758
  //# sourceMappingURL=index.js.map