qvdjs 0.10.0 → 0.11.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.cjs CHANGED
@@ -262,6 +262,128 @@ var init_QvdSymbol = __esm({
262
262
  };
263
263
  }
264
264
  });
265
+
266
+ // src/util/readOptions.js
267
+ function requireRowCount(value, name, filePath) {
268
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
269
+ throw new exports.QvdValidationError(`${name} must be a non-negative integer`, {
270
+ option: name,
271
+ provided: value,
272
+ type: typeof value,
273
+ file: filePath
274
+ });
275
+ }
276
+ return value;
277
+ }
278
+ function normaliseWindow(window, filePath) {
279
+ if (window === null || window === void 0) {
280
+ return { offset: 0, limit: null };
281
+ }
282
+ if (typeof window === "number") {
283
+ return { offset: 0, limit: requireRowCount(window, "maxRows", filePath) };
284
+ }
285
+ if (typeof window !== "object" || Array.isArray(window)) {
286
+ throw new exports.QvdValidationError("The row window must be a number, null, or an {offset, limit} object", {
287
+ provided: window,
288
+ type: typeof window,
289
+ file: filePath
290
+ });
291
+ }
292
+ const { offset, limit, maxRows } = window;
293
+ const limitGiven = limit !== void 0 && limit !== null;
294
+ const maxRowsGiven = maxRows !== void 0 && maxRows !== null;
295
+ if (limitGiven && maxRowsGiven) {
296
+ throw new exports.QvdValidationError("maxRows and limit are two names for the same option; pass one of them, not both", {
297
+ maxRows,
298
+ limit,
299
+ file: filePath
300
+ });
301
+ }
302
+ return {
303
+ offset: offset === void 0 || offset === null ? 0 : requireRowCount(offset, "offset", filePath),
304
+ limit: limitGiven ? requireRowCount(limit, "limit", filePath) : maxRowsGiven ? requireRowCount(maxRows, "maxRows", filePath) : null
305
+ };
306
+ }
307
+ function resolveWindow(window, totalRows) {
308
+ const rows = Number.isSafeInteger(totalRows) && totalRows > 0 ? totalRows : 0;
309
+ const offset = Math.min(window.offset, rows);
310
+ return {
311
+ offset,
312
+ limit: Math.max(0, Math.min(window.limit === null ? Infinity : window.limit, rows - offset))
313
+ };
314
+ }
315
+ function selectFields(fields, requested, filePath) {
316
+ if (requested === null || requested === void 0) {
317
+ return fields;
318
+ }
319
+ if (!Array.isArray(requested)) {
320
+ throw new exports.QvdValidationError("fields must be an array of field names", {
321
+ provided: requested,
322
+ type: typeof requested,
323
+ file: filePath
324
+ });
325
+ }
326
+ const available = fields.map((field) => field["FieldName"]);
327
+ if (requested.length === 0) {
328
+ throw new exports.QvdValidationError("fields must name at least one field", {
329
+ availableColumns: available,
330
+ file: filePath
331
+ });
332
+ }
333
+ const seen = /* @__PURE__ */ new Set();
334
+ return requested.map((name) => {
335
+ if (typeof name !== "string") {
336
+ throw new exports.QvdValidationError("Field names must be strings", {
337
+ provided: name,
338
+ type: typeof name,
339
+ availableColumns: available,
340
+ file: filePath
341
+ });
342
+ }
343
+ if (seen.has(name)) {
344
+ throw new exports.QvdValidationError(`Field '${name}' is listed twice`, {
345
+ column: name,
346
+ fields: requested,
347
+ file: filePath
348
+ });
349
+ }
350
+ seen.add(name);
351
+ const index = available.indexOf(name);
352
+ if (index === -1) {
353
+ throw new exports.QvdValidationError(`Column '${name}' does not exist`, {
354
+ column: name,
355
+ availableColumns: available,
356
+ file: filePath
357
+ });
358
+ }
359
+ return fields[index];
360
+ });
361
+ }
362
+ function readerOptionsFrom(options) {
363
+ return {
364
+ allowedDir: options.allowedDir,
365
+ memorySafetyFactor: options.memorySafetyFactor,
366
+ symbolFilteringThreshold: options.symbolFilteringThreshold,
367
+ fields: options.fields === void 0 ? null : options.fields,
368
+ onProgress: options.onProgress,
369
+ signal: options.signal
370
+ };
371
+ }
372
+ function metadataOptionsFrom(options) {
373
+ return {
374
+ allowedDir: options.allowedDir,
375
+ onProgress: options.onProgress,
376
+ signal: options.signal
377
+ };
378
+ }
379
+ function windowFrom(options) {
380
+ return { offset: options.offset, limit: options.limit, maxRows: options.maxRows };
381
+ }
382
+ var init_readOptions = __esm({
383
+ "src/util/readOptions.js"() {
384
+ init_QvdErrors();
385
+ }
386
+ });
265
387
  function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
266
388
  const isCaseInsensitiveFS = process.platform === "win32";
267
389
  const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
@@ -819,11 +941,12 @@ function estimateRowMemory(rows, columnCount) {
819
941
  }
820
942
  return BASE_BYTES + rows * (ROW_BASE_BYTES + PER_CELL_BYTES * columnCount);
821
943
  }
822
- function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true) {
944
+ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, rowsLive = null) {
823
945
  const FULL_PARSE_OVERHEAD = 6;
824
946
  const MINIMAL_OVERHEAD = 0.01;
825
947
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
826
- const rowMemory = materialisesRows ? estimateRowMemory(rowsToLoad, columnCount) : BASE_BYTES;
948
+ const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
949
+ const rowMemory = materialisesRows ? estimateRowMemory(liveRows, columnCount) : BASE_BYTES;
827
950
  if (maxRows === null || maxRows >= totalRows) {
828
951
  return symbolTableSize * FULL_PARSE_OVERHEAD + rowMemory;
829
952
  }
@@ -833,18 +956,44 @@ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount =
833
956
  const skippedSymbolsMemory = symbolTableSize * (1 - symbolPercentage) * MINIMAL_OVERHEAD;
834
957
  return keptSymbolsMemory + skippedSymbolsMemory + rowMemory;
835
958
  }
836
- function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true) {
837
- if (estimateMemoryUsage(symbolTableSize, totalRows, totalRows, columnCount, materialisesRows) <= budget) {
959
+ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true, includeExternal = false) {
960
+ const costOf = (rows) => estimateMemoryUsage(symbolTableSize, rows, totalRows, columnCount, materialisesRows) + (includeExternal ? estimateExternalMemory(Math.min(rows, totalRows), columnCount) : 0);
961
+ if (costOf(totalRows) <= budget) {
838
962
  return totalRows;
839
963
  }
840
- if (estimateMemoryUsage(symbolTableSize, 0, totalRows, columnCount, materialisesRows) > budget) {
964
+ if (costOf(0) > budget) {
841
965
  return 0;
842
966
  }
843
967
  let low = 0;
844
968
  let high = totalRows;
845
969
  while (high - low > 1) {
846
970
  const mid = Math.floor((low + high) / 2);
847
- if (estimateMemoryUsage(symbolTableSize, mid, totalRows, columnCount, materialisesRows) <= budget) {
971
+ if (costOf(mid) <= budget) {
972
+ low = mid;
973
+ } else {
974
+ high = mid;
975
+ }
976
+ }
977
+ return low;
978
+ }
979
+ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, columnCount, liveRowsPerChunk = 1, includeExternal = false) {
980
+ const covered = windowRows === null || windowRows >= totalRows ? totalRows : windowRows;
981
+ const fits = (chunk) => {
982
+ const live = Math.min(chunk * liveRowsPerChunk, covered);
983
+ const cost = estimateMemoryUsage(symbolTableSize, windowRows, totalRows, columnCount, true, chunk * liveRowsPerChunk) + (includeExternal ? estimateExternalMemory(live, columnCount) : 0);
984
+ return cost <= budget;
985
+ };
986
+ if (fits(covered)) {
987
+ return covered;
988
+ }
989
+ if (!fits(1)) {
990
+ return 0;
991
+ }
992
+ let low = 1;
993
+ let high = covered;
994
+ while (high - low > 1) {
995
+ const mid = Math.floor((low + high) / 2);
996
+ if (fits(mid)) {
848
997
  low = mid;
849
998
  } else {
850
999
  high = mid;
@@ -852,7 +1001,7 @@ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, mat
852
1001
  }
853
1002
  return low;
854
1003
  }
855
- function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true) {
1004
+ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null) {
856
1005
  if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
857
1006
  throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
858
1007
  }
@@ -861,12 +1010,16 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
861
1010
  }
862
1011
  const budget = getMemoryBudget();
863
1012
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
864
- const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows);
865
- const externalMemory = estimateExternalMemory(rowsToLoad, columnCount);
1013
+ const rowsLive = live === null ? null : live.rows;
1014
+ const liveRowsPerChunk = live === null ? 1 : live.perChunk;
1015
+ const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
1016
+ const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows, rowsLive);
1017
+ const externalMemory = estimateExternalMemory(liveRows, columnCount);
866
1018
  const bounded = budget.candidates.map((candidate) => {
867
1019
  const heapOnly = candidate.source === "V8 heap limit";
868
1020
  return {
869
1021
  ...candidate,
1022
+ heapOnly,
870
1023
  needs: heapOnly ? heapMemory : heapMemory + externalMemory,
871
1024
  allowed: candidate.bytes * safetyFactor,
872
1025
  bounds: heapOnly ? "the V8 heap" : "the whole process"
@@ -882,12 +1035,14 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
882
1035
  const estimatedMemory = binding ? binding.needs : heapMemory;
883
1036
  const maxAllowedMemory = binding ? binding.allowed : budget.bytes * safetyFactor;
884
1037
  if (binding) {
1038
+ const includeExternal = !binding.heapOnly;
885
1039
  const recommendedMaxRows = recommendedRowsFor(
886
1040
  maxAllowedMemory,
887
1041
  symbolTableSize,
888
1042
  totalRows,
889
1043
  columnCount,
890
- materialisesRows
1044
+ materialisesRows,
1045
+ includeExternal
891
1046
  );
892
1047
  const sizeMB = Math.round(symbolTableSize / 1024 / 1024);
893
1048
  const estimatedMB = Math.round(estimatedMemory / 1024 / 1024);
@@ -899,15 +1054,27 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
899
1054
  const limitingScope = binding.bounds;
900
1055
  const budgetBreakdown = budget.candidates.map((candidate) => `${candidate.source} ${Math.round(candidate.bytes / 1024 / 1024)}MB`).join(", ");
901
1056
  const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
902
- const nothingFits = recommendedMaxRows === 0;
903
1057
  const containerBound = binding.source === "container memory limit";
1058
+ const chunked = rowsLive !== null;
1059
+ const recommendedChunk = chunked ? recommendedChunkFor(
1060
+ maxAllowedMemory,
1061
+ symbolTableSize,
1062
+ maxRows,
1063
+ totalRows,
1064
+ columnCount,
1065
+ liveRowsPerChunk,
1066
+ includeExternal
1067
+ ) : 0;
1068
+ const knob = chunked ? "chunkSize" : "limit";
1069
+ const recommendedValue = chunked ? recommendedChunk : recommendedMaxRows;
1070
+ const nothingFits = recommendedValue === 0;
904
1071
  let advice;
905
1072
  if (nothingFits) {
906
- advice = `No row count fits this budget - the symbol table alone exceeds it, so maxRows cannot help. ` + (containerBound ? `Raise the container's memory limit.` : `Raise the heap with --max-old-space-size, or raise memorySafetyFactor.`);
1073
+ advice = `No row count fits this budget - the symbol table alone exceeds it, so ${knob} cannot help. ` + (containerBound ? `Raise the container's memory limit.` : `Raise the heap with --max-old-space-size, or raise memorySafetyFactor.`);
907
1074
  } else if (containerBound) {
908
- advice = `The binding limit is the container's, so raising --max-old-space-size would let V8 grow past it and be killed by the OOM killer instead. Set it below the container limit, raise the limit, or load fewer rows with maxRows (recommended: ${recommendedMaxRows.toLocaleString()} rows or less).`;
1075
+ advice = `The binding limit is the container's, so raising --max-old-space-size would let V8 grow past it and be killed by the OOM killer instead. Set it below the container limit, raise the limit, or hold fewer rows with ${knob} (recommended: ${recommendedValue.toLocaleString()} rows or less).`;
909
1076
  } else {
910
- advice = `Try loading fewer rows using the maxRows parameter (recommended: ${recommendedMaxRows.toLocaleString()} rows or less), or raise the heap with --max-old-space-size.`;
1077
+ advice = `Try holding fewer rows using the ${knob} parameter (recommended: ${recommendedValue.toLocaleString()} rows or less), or raise the heap with --max-old-space-size.`;
911
1078
  }
912
1079
  throw new exports.QvdValidationError(
913
1080
  `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,
@@ -927,22 +1094,30 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
927
1094
  columnCount,
928
1095
  totalRows,
929
1096
  maxRows,
930
- recommendedMaxRows
1097
+ recommendedMaxRows,
1098
+ // Only present when a chunk size is what overflowed, so a caller cannot mistake one
1099
+ // recommendation for the other.
1100
+ ...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
931
1101
  }
932
1102
  );
933
1103
  }
934
1104
  }
935
1105
  function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true) {
936
1106
  const LARGE_SYMBOL_TABLE_WARNING = usableOldSpaceLimit() * 0.125;
937
- if (symbolTableSize > LARGE_SYMBOL_TABLE_WARNING && (maxRows === null || maxRows >= totalRows)) {
938
- const sizeMB = Math.round(symbolTableSize / 1024 / 1024);
939
- const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows);
940
- const estimatedMB = Math.round(estimatedMemory / 1024 / 1024);
941
- const warnMB = Math.round(LARGE_SYMBOL_TABLE_WARNING / 1024 / 1024);
942
- console.warn(
943
- `\u26A0\uFE0F Large symbol table detected (${sizeMB}MB > ${warnMB}MB threshold). Loading all ${totalRows.toLocaleString()} rows will use ~${estimatedMB}MB RAM. Consider using the maxRows parameter for better performance and lower memory usage.`
944
- );
1107
+ if (symbolTableSize <= LARGE_SYMBOL_TABLE_WARNING) {
1108
+ return;
1109
+ }
1110
+ const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
1111
+ const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows);
1112
+ if (estimatedMemory <= LARGE_SYMBOL_TABLE_WARNING) {
1113
+ return;
945
1114
  }
1115
+ const sizeMB = Math.round(symbolTableSize / 1024 / 1024);
1116
+ const estimatedMB = Math.round(estimatedMemory / 1024 / 1024);
1117
+ const warnMB = Math.round(LARGE_SYMBOL_TABLE_WARNING / 1024 / 1024);
1118
+ console.warn(
1119
+ `\u26A0\uFE0F Large symbol table detected (${sizeMB}MB > ${warnMB}MB threshold). This read materialises ${rowsToLoad.toLocaleString()} of ${totalRows.toLocaleString()} 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.`
1120
+ );
946
1121
  }
947
1122
  var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES;
948
1123
  var init_memoryUtils = __esm({
@@ -957,6 +1132,24 @@ var init_memoryUtils = __esm({
957
1132
  });
958
1133
 
959
1134
  // src/util/validationUtils.js
1135
+ function validateHeaderStructure(headerObj, filePath, stage) {
1136
+ const tableHeader = headerObj?.["QvdTableHeader"];
1137
+ if (tableHeader === null || typeof tableHeader !== "object" || Array.isArray(tableHeader)) {
1138
+ throw new exports.QvdCorruptedError("The XML header contains no usable QvdTableHeader element", {
1139
+ rootElements: headerObj && typeof headerObj === "object" ? Object.keys(headerObj) : [],
1140
+ file: filePath,
1141
+ stage
1142
+ });
1143
+ }
1144
+ const symbolTableLength = parseInt(tableHeader["Offset"], 10);
1145
+ if (isNaN(symbolTableLength) || !Number.isSafeInteger(symbolTableLength) || symbolTableLength < 0) {
1146
+ throw new exports.QvdCorruptedError("Invalid symbol table offset", {
1147
+ offset: tableHeader["Offset"],
1148
+ file: filePath,
1149
+ stage
1150
+ });
1151
+ }
1152
+ }
960
1153
  function validateSymbolTableSizeEarly(symbolTableLength, filePath) {
961
1154
  const heapLimit = getHeapLimit();
962
1155
  const MAX_SYMBOL_TABLE_SIZE = heapLimit * 0.125;
@@ -965,7 +1158,7 @@ function validateSymbolTableSizeEarly(symbolTableLength, filePath) {
965
1158
  const maxMB = Math.round(MAX_SYMBOL_TABLE_SIZE / 1024 / 1024);
966
1159
  const heapMB = Math.round(heapLimit / 1024 / 1024);
967
1160
  throw new exports.QvdValidationError(
968
- `Symbol table too large (${sizeMB}MB exceeds ${maxMB}MB limit for lazy loading). This QVD file contains extremely high-cardinality fields. Limit scales with heap size (current: ${heapMB}MB, limit: 12.5% = ${maxMB}MB). Consider: (1) loading the full file without maxRows, (2) increasing heap size with --max-old-space-size, or (3) aggregating high-cardinality fields.`,
1161
+ `Symbol table too large (${sizeMB}MB exceeds ${maxMB}MB limit for lazy loading). This QVD file contains extremely high-cardinality fields. Limit scales with heap size (current: ${heapMB}MB, limit: 12.5% = ${maxMB}MB). Consider: (1) loading the full file without a row window - maxRows, limit or offset - since the symbol table is read in full either way, (2) increasing heap size with --max-old-space-size, or (3) aggregating high-cardinality fields.`,
969
1162
  {
970
1163
  file: filePath,
971
1164
  symbolTableSize: symbolTableLength,
@@ -1039,7 +1232,7 @@ function validateRecordCount(totalRows, filePath, stage = "parseIndexTable") {
1039
1232
  });
1040
1233
  }
1041
1234
  }
1042
- function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null) {
1235
+ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null, windowFirstRow = 0, bufferFirstRow = 0) {
1043
1236
  if (isNaN(recordSize) || !Number.isSafeInteger(recordSize) || recordSize < 0) {
1044
1237
  throw new exports.QvdCorruptedError("Invalid record byte size", {
1045
1238
  recordSize,
@@ -1101,23 +1294,28 @@ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, ind
1101
1294
  }
1102
1295
  }
1103
1296
  const requiredIndexBytes = rowsToLoad * recordSize;
1104
- if (indexTableOffset + requiredIndexBytes > bufferLength) {
1297
+ const bufferRecordStart = (windowFirstRow - bufferFirstRow) * recordSize;
1298
+ if (indexTableOffset + bufferRecordStart + requiredIndexBytes > bufferLength) {
1105
1299
  throw new exports.QvdCorruptedError("Index table truncated", {
1106
1300
  indexTableOffset,
1107
1301
  requiredBytes: requiredIndexBytes,
1108
- availableBytes: Math.max(0, bufferLength - indexTableOffset),
1302
+ availableBytes: Math.max(0, bufferLength - indexTableOffset - bufferRecordStart),
1109
1303
  rowsToLoad,
1304
+ windowFirstRow,
1305
+ bufferFirstRow,
1110
1306
  recordSize,
1111
1307
  bufferSize: bufferLength,
1112
1308
  file: filePath,
1113
1309
  stage: "parseIndexTable"
1114
1310
  });
1115
1311
  }
1116
- if (indexTableLength < requiredIndexBytes) {
1312
+ const requiredTableBytes = (windowFirstRow + rowsToLoad) * recordSize;
1313
+ if (indexTableLength < requiredTableBytes) {
1117
1314
  throw new exports.QvdCorruptedError("Index table length smaller than required", {
1118
1315
  indexTableLength,
1119
- requiredBytes: requiredIndexBytes,
1316
+ requiredBytes: requiredTableBytes,
1120
1317
  rowsToLoad,
1318
+ windowFirstRow,
1121
1319
  recordSize,
1122
1320
  file: filePath,
1123
1321
  stage: "parseIndexTable"
@@ -1447,6 +1645,7 @@ exports.QvdColumn = void 0; exports.QvdColumnTable = void 0;
1447
1645
  var init_QvdColumnTable = __esm({
1448
1646
  "src/QvdColumnTable.js"() {
1449
1647
  init_QvdErrors();
1648
+ init_readOptions();
1450
1649
  exports.QvdColumn = class {
1451
1650
  /**
1452
1651
  * @param {string} name The field name.
@@ -1641,9 +1840,21 @@ var init_QvdColumnTable = __esm({
1641
1840
  /**
1642
1841
  * Reads a QVD file as columns.
1643
1842
  *
1843
+ * Takes the same options as `QvdDataFrame.fromQvd`, with the same meanings - one option
1844
+ * vocabulary for both read paths, because they are two answers about the same file rather than
1845
+ * two features. `{offset, limit}` is how a caller pages through a file columnwise; there is no
1846
+ * columnar `iterate()` because there is nothing for it to bound - a columnar read materialises
1847
+ * no rows, which is the memory chunking exists to cap.
1848
+ *
1644
1849
  * @param {string} path The path to the QVD file.
1645
1850
  * @param {Object} [options] Loading options, with the same meanings they have on `fromQvd`.
1646
- * @param {number|null} [options.maxRows] Maximum rows to decode.
1851
+ * @param {number|null} [options.maxRows] Rows to decode. The older name for `limit`.
1852
+ * @param {number|null} [options.limit] Rows to decode, counting from `offset`.
1853
+ * @param {number} [options.offset] File row to start at.
1854
+ * @param {Array<string>|null} [options.fields] Field names to read, in the order they should
1855
+ * appear. Unselected fields have their symbols skipped entirely.
1856
+ * @param {Function} [options.onProgress] Progress callback, `{stage, current, total, percent}`.
1857
+ * @param {AbortSignal} [options.signal] Cancels the read.
1647
1858
  * @param {string} [options.allowedDir] Directory the path must resolve inside.
1648
1859
  * @param {number} [options.memorySafetyFactor] Fraction of the memory budget a load may use.
1649
1860
  * @param {number} [options.symbolFilteringThreshold] Symbol table size above which a limited
@@ -1653,15 +1864,13 @@ var init_QvdColumnTable = __esm({
1653
1864
  static async fromQvd(path3, options = {}) {
1654
1865
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
1655
1866
  const reader = new QvdFileReader2(path3, {
1656
- allowedDir: options.allowedDir,
1657
- memorySafetyFactor: options.memorySafetyFactor,
1658
- symbolFilteringThreshold: options.symbolFilteringThreshold,
1867
+ ...readerOptionsFrom(options),
1659
1868
  // This read builds no rows, so the memory guard must not charge it for them. A columnar
1660
1869
  // read of the 38MB taxi fixture completes in a 15MB heap; charged the row cost it was
1661
1870
  // refused below a 2GB one.
1662
1871
  materialisesRows: false
1663
1872
  });
1664
- return await reader.loadColumnar(options.maxRows !== void 0 ? options.maxRows : null);
1873
+ return await reader.loadColumnar(windowFrom(options));
1665
1874
  }
1666
1875
  /** @return {Array<string>} Field names, in file order. */
1667
1876
  get columns() {
@@ -1709,7 +1918,7 @@ var QvdFileReader_exports = {};
1709
1918
  __export(QvdFileReader_exports, {
1710
1919
  QvdFileReader: () => exports.QvdFileReader
1711
1920
  });
1712
- var MAX_HEADER_SIZE, READ_CHUNK_SIZE; exports.QvdFileReader = void 0;
1921
+ var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS; exports.QvdFileReader = void 0;
1713
1922
  var init_QvdFileReader = __esm({
1714
1923
  "src/QvdFileReader.js"() {
1715
1924
  init_QvdDataFrame();
@@ -1719,8 +1928,10 @@ var init_QvdFileReader = __esm({
1719
1928
  init_memoryUtils();
1720
1929
  init_validationUtils();
1721
1930
  init_symbolParser();
1931
+ init_readOptions();
1722
1932
  MAX_HEADER_SIZE = 16 * 1024 * 1024;
1723
1933
  READ_CHUNK_SIZE = 512 * 1024 * 1024;
1934
+ ANALYSIS_SLICE_ROWS = 65536;
1724
1935
  exports.QvdFileReader = class {
1725
1936
  /**
1726
1937
  * Constructs a new QVD file parser.
@@ -1732,9 +1943,10 @@ var init_QvdFileReader = __esm({
1732
1943
  * points outside it is rejected. Defaults to the current working directory. To permit
1733
1944
  * an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
1734
1945
  * empty value falls back to the working directory rather than removing the restriction.
1735
- * @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a
1736
- * load may use. The budget is the smallest of the V8 heap limit, any container memory limit,
1737
- * and the memory the OS reports as available. Default is 0.3. **Zero disables the memory
1946
+ * @param {number} [options.memorySafetyFactor=0.8] Fraction (0.0-1.0) of the memory budget a
1947
+ * load may use. The budget is the smaller of the V8 heap limit and any container memory limit;
1948
+ * what the OS reports as available is recorded for diagnostics and deliberately not allowed to
1949
+ * bind - see `getMemoryBudget`. Default is 0.8. **Zero disables the memory
1738
1950
  * check entirely**, which is the escape hatch for runtimes whose limits cannot be measured -
1739
1951
  * Bun reports its current heap as its heap limit - and for callers who would rather manage
1740
1952
  * memory themselves than trust the estimate.
@@ -1745,29 +1957,93 @@ var init_QvdFileReader = __esm({
1745
1957
  * above which a lazy load switches to the two-pass filtering path. The default of 50MB is
1746
1958
  * the point where the extra analysis pass pays for itself; lower it to use filtering on
1747
1959
  * smaller files, raise it to keep the simpler single-pass read for longer.
1960
+ * @param {Array<string>|null} [options.fields] Field names to read, in the order they should
1961
+ * appear. Null reads every field, in file order. An unknown or repeated name is refused.
1962
+ * @param {Function} [options.onProgress] Called with `{stage, current, total, percent}` as the
1963
+ * read proceeds - the same shape `QvdFileWriter` emits.
1964
+ * @param {AbortSignal} [options.signal] Cancels the read. When it is aborted the read throws
1965
+ * `signal.reason`, exactly as `signal.throwIfAborted()` does.
1748
1966
  */
1749
1967
  constructor(filePath, options = {}) {
1750
1968
  const {
1751
1969
  allowedDir,
1752
1970
  memorySafetyFactor = 0.8,
1753
1971
  symbolFilteringThreshold = 50 * 1024 * 1024,
1754
- materialisesRows = true
1972
+ materialisesRows = true,
1973
+ fields = null,
1974
+ onProgress,
1975
+ signal
1755
1976
  } = options;
1756
1977
  this._materialisesRows = materialisesRows;
1757
1978
  this._path = validatePath(filePath, allowedDir);
1758
1979
  this._memorySafetyFactor = memorySafetyFactor;
1759
1980
  this._symbolFilteringThreshold = symbolFilteringThreshold;
1981
+ if (onProgress !== void 0 && typeof onProgress !== "function") {
1982
+ throw new exports.QvdValidationError("onProgress must be a function", {
1983
+ provided: onProgress,
1984
+ type: typeof onProgress,
1985
+ file: this._path
1986
+ });
1987
+ }
1988
+ if (signal !== void 0 && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean")) {
1989
+ throw new exports.QvdValidationError("signal must be an AbortSignal", {
1990
+ provided: signal,
1991
+ type: typeof signal,
1992
+ file: this._path
1993
+ });
1994
+ }
1995
+ this._requestedFields = fields === void 0 ? null : fields;
1996
+ this._onProgress = onProgress;
1997
+ this._signal = signal;
1760
1998
  this._buffer = null;
1761
1999
  this._headerOffset = null;
1762
2000
  this._symbolTableOffset = null;
1763
2001
  this._indexTableOffset = null;
1764
2002
  this._header = null;
2003
+ this._allFields = null;
2004
+ this._selectedFields = null;
2005
+ this._fieldBitMetadataValidated = false;
1765
2006
  this._symbolTable = null;
1766
2007
  this._indexColumns = null;
1767
2008
  this._rowsDecoded = 0;
2009
+ this._bufferFirstRow = 0;
1768
2010
  this._fileSize = null;
1769
2011
  this._headerMatchesFile = false;
1770
2012
  }
2013
+ /**
2014
+ * Emits a progress event if a callback is registered.
2015
+ *
2016
+ * The same shape `QvdFileWriter._emitProgress` emits, deliberately: a caller who has written a
2017
+ * progress bar for a write should not have to write a second one for a read. The stage names
2018
+ * differ because the stages differ, but `symbol-table` and `index-table` mean the same thing on
2019
+ * both sides.
2020
+ *
2021
+ * @param {string} stage The current stage of the read.
2022
+ * @param {number} current The current progress value.
2023
+ * @param {number} total The total progress value.
2024
+ * @private
2025
+ */
2026
+ _emitProgress(stage, current, total) {
2027
+ if (this._onProgress) {
2028
+ const percent = total > 0 ? Math.round(current / total * 100) : 100;
2029
+ this._onProgress({ stage, current, total, percent });
2030
+ }
2031
+ }
2032
+ /**
2033
+ * Throws if the caller has cancelled the read.
2034
+ *
2035
+ * Throws `signal.reason` - a `DOMException` named `AbortError` unless the caller aborted with a
2036
+ * reason of their own. That is what `AbortSignal` means everywhere else in Node, and inventing
2037
+ * a `QvdAbortError` here would make this library's cancellation the one a caller has to special
2038
+ * case.
2039
+ *
2040
+ * @private
2041
+ */
2042
+ _throwIfAborted() {
2043
+ if (this._signal) {
2044
+ this._signal.throwIfAborted();
2045
+ }
2046
+ }
1771
2047
  /**
1772
2048
  * Reads the binary data of the QVD file.
1773
2049
  *
@@ -1792,14 +2068,23 @@ var init_QvdFileReader = __esm({
1792
2068
  * - Streaming for header finding is efficient for unknown header sizes
1793
2069
  * - Direct byte-range reading for remaining data is fastest
1794
2070
  *
1795
- * @param {number|null} maxRows The maximum number of rows to load. If null, all data is loaded.
2071
+ * A window with a non-zero `offset` reads two ranges rather than one: the header and symbol
2072
+ * table from the front of the file, and the window's records from wherever they sit. The bytes
2073
+ * between are never read, which is what makes `{offset: 1_700_000, limit: 100}` on the taxi
2074
+ * fixture a 0.4MB read rather than a 38MB one.
2075
+ *
2076
+ * @param {QvdRowWindow} window The rows to read.
1796
2077
  * @param {boolean} [headerOnly=false] Stop once the XML header has been read, leaving the
1797
2078
  * symbol and index tables on disk. This is the metadata-only path: the header is a few
1798
2079
  * kilobytes whatever the file's size, so reading a schema costs the same for a 40MB file as
1799
2080
  * for a 40GB one.
2081
+ * @param {{rows: number, perChunk: number}|null} [liveRows=null] Rows held at one instant when
2082
+ * that is fewer than the window covers - see `_prepare`.
1800
2083
  * @private
1801
2084
  */
1802
- async _readData(maxRows = null, headerOnly = false) {
2085
+ async _readData(window = { offset: 0, limit: null }, headerOnly = false, liveRows = null) {
2086
+ this._throwIfAborted();
2087
+ this._emitProgress("read", 0, 1);
1803
2088
  const HEADER_DELIMITER = "\r\n\0";
1804
2089
  const CHUNK_SIZE = 64 * 1024;
1805
2090
  const stream = fs__default.default.createReadStream(this._path, {
@@ -1861,6 +2146,7 @@ var init_QvdFileReader = __esm({
1861
2146
  stage: "readData"
1862
2147
  });
1863
2148
  }
2149
+ validateHeaderStructure(headerObj, this._path, "readData");
1864
2150
  const symbolTableOffset = headerEndIndex;
1865
2151
  const symbolTableLength = parseInt(headerObj["QvdTableHeader"]["Offset"], 10);
1866
2152
  const indexTableOffset = symbolTableOffset + symbolTableLength;
@@ -1868,13 +2154,14 @@ var init_QvdFileReader = __esm({
1868
2154
  const totalRows = parseInt(headerObj["QvdTableHeader"]["NoOfRecords"], 10);
1869
2155
  if (headerOnly) {
1870
2156
  this._buffer = headerBuffer.subarray(0, headerEndIndex);
2157
+ this._emitProgress("read", 1, 1);
1871
2158
  return;
1872
2159
  }
1873
2160
  let headerFields = headerObj["QvdTableHeader"]?.["Fields"]?.["QvdFieldHeader"];
1874
2161
  if (headerFields && !Array.isArray(headerFields)) {
1875
2162
  headerFields = [headerFields];
1876
2163
  }
1877
- const columnCount = Array.isArray(headerFields) ? headerFields.length : 0;
2164
+ const columnCount = Array.isArray(headerFields) ? selectFields(headerFields, this._requestedFields, this._path).length : 0;
1878
2165
  const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
1879
2166
  (value) => Number.isSafeInteger(value) && value >= 0
1880
2167
  );
@@ -1883,23 +2170,28 @@ var init_QvdFileReader = __esm({
1883
2170
  this._fileSize = fileSize;
1884
2171
  this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
1885
2172
  }
2173
+ const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
2174
+ const windowRows = resolved.limit;
1886
2175
  if (headerNumbersUsable && this._headerMatchesFile) {
1887
2176
  validateMemoryAvailability(
1888
2177
  symbolTableLength,
1889
- maxRows,
2178
+ windowRows,
1890
2179
  totalRows,
1891
2180
  this._path,
1892
2181
  this._memorySafetyFactor,
1893
2182
  columnCount,
1894
- this._materialisesRows
2183
+ this._materialisesRows,
2184
+ liveRows
1895
2185
  );
1896
2186
  }
1897
- if (maxRows === null) {
2187
+ if (window.offset === 0 && window.limit === null) {
1898
2188
  this._buffer = await fs__default.default.promises.readFile(this._path);
1899
2189
  this._fileSize = this._buffer.length;
2190
+ this._bufferFirstRow = 0;
2191
+ this._emitProgress("read", 1, 1);
1900
2192
  return;
1901
2193
  }
1902
- const rowsToLoad = Math.min(maxRows, totalRows);
2194
+ const rowsToLoad = windowRows;
1903
2195
  validateSymbolTableSizeEarly(symbolTableLength, this._path);
1904
2196
  for (const [name, value] of [
1905
2197
  ["Offset", symbolTableLength],
@@ -1915,39 +2207,78 @@ var init_QvdFileReader = __esm({
1915
2207
  });
1916
2208
  }
1917
2209
  }
2210
+ const skippedIndexBytes = resolved.offset * recordSize;
1918
2211
  const indexTableBytesToRead = rowsToLoad * recordSize;
1919
2212
  const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
2213
+ const fileBytesRequired = indexTableOffset + skippedIndexBytes + indexTableBytesToRead;
1920
2214
  const fd = await fs__default.default.promises.open(this._path, "r");
1921
2215
  try {
1922
2216
  const { size: fileSize } = await fd.stat();
1923
2217
  this._fileSize = fileSize;
1924
- if (totalBytesToRead > fileSize) {
2218
+ if (fileBytesRequired > fileSize) {
1925
2219
  throw new exports.QvdCorruptedError("The file is shorter than its header claims.", {
1926
2220
  file: this._path,
1927
2221
  fileSize,
1928
- requiredBytes: totalBytesToRead,
2222
+ requiredBytes: fileBytesRequired,
1929
2223
  stage: "readData"
1930
2224
  });
1931
2225
  }
1932
2226
  this._buffer = Buffer.alloc(totalBytesToRead);
1933
- let position = 0;
1934
- while (position < totalBytesToRead) {
1935
- const length = Math.min(READ_CHUNK_SIZE, totalBytesToRead - position);
1936
- const { bytesRead } = await fd.read(this._buffer, position, length, position);
1937
- if (bytesRead === 0) {
1938
- throw new exports.QvdCorruptedError("Unexpected end of file while reading QVD data.", {
1939
- file: this._path,
1940
- fileSize,
1941
- bytesRead: position,
1942
- requiredBytes: totalBytesToRead,
1943
- stage: "readData"
1944
- });
1945
- }
1946
- position += bytesRead;
2227
+ await this._readRange(fd, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
2228
+ if (indexTableBytesToRead > 0) {
2229
+ await this._readRange(
2230
+ fd,
2231
+ indexTableOffset,
2232
+ indexTableBytesToRead,
2233
+ indexTableOffset + skippedIndexBytes,
2234
+ fileSize,
2235
+ fileBytesRequired
2236
+ );
1947
2237
  }
2238
+ this._bufferFirstRow = resolved.offset;
1948
2239
  } finally {
1949
2240
  await fd.close();
1950
2241
  }
2242
+ this._emitProgress("read", 1, 1);
2243
+ }
2244
+ /**
2245
+ * Reads one byte range of the file into the buffer.
2246
+ *
2247
+ * Read in bounded chunks, checking bytesRead each time. A single fs.read call with a length of
2248
+ * 2^31 or more does not throw - it trips a C++ assertion and aborts the whole process, which no
2249
+ * try/catch can intercept.
2250
+ *
2251
+ * @param {import('fs/promises').FileHandle} fd The open file.
2252
+ * @param {number} bufferOffset Where in the buffer to write.
2253
+ * @param {number} byteCount How many bytes to read.
2254
+ * @param {number} filePosition Where in the file to read from.
2255
+ * @param {number} fileSize The file's size, for the error.
2256
+ * @param {number} requiredBytes Bytes the whole read needs, for the error.
2257
+ * @private
2258
+ */
2259
+ async _readRange(fd, bufferOffset, byteCount, filePosition, fileSize, requiredBytes) {
2260
+ assert2__default.default(this._buffer, "The read buffer has not been allocated.");
2261
+ let done = 0;
2262
+ while (done < byteCount) {
2263
+ const length = Math.min(READ_CHUNK_SIZE, byteCount - done);
2264
+ const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done);
2265
+ if (bytesRead === 0) {
2266
+ throw new exports.QvdCorruptedError("Unexpected end of file while reading QVD data.", {
2267
+ file: this._path,
2268
+ fileSize,
2269
+ // Two numbers, because they stopped being the same one when a window began reading two
2270
+ // ranges: `bytesRead` is how much of this range arrived, `filePosition` is where in the
2271
+ // file it gave up. Reporting the position under the name of the count made a windowed
2272
+ // read of a truncated file claim tens of megabytes had been read when a few hundred
2273
+ // bytes had.
2274
+ bytesRead: done,
2275
+ filePosition: filePosition + done,
2276
+ requiredBytes,
2277
+ stage: "readData"
2278
+ });
2279
+ }
2280
+ done += bytesRead;
2281
+ }
1951
2282
  }
1952
2283
  /**
1953
2284
  * Parses the XML header of the QVD file. This method is part of the parsing process
@@ -1984,17 +2315,31 @@ var init_QvdFileReader = __esm({
1984
2315
  stage: "parseHeader"
1985
2316
  });
1986
2317
  }
2318
+ validateHeaderStructure(this._header, this._path, "parseHeader");
1987
2319
  const fields = this._header["QvdTableHeader"]?.["Fields"]?.["QvdFieldHeader"];
1988
- const fieldCount = fields === void 0 || fields === null ? 0 : Array.isArray(fields) ? fields.length : 1;
1989
- if (fieldCount === 0) {
2320
+ const fieldList = fields === void 0 || fields === null ? [] : Array.isArray(fields) ? fields : [fields];
2321
+ if (fieldList.length === 0) {
1990
2322
  throw new exports.QvdCorruptedError("The QVD file header declares no fields", {
1991
2323
  file: this._path,
1992
2324
  stage: "parseHeader"
1993
2325
  });
1994
2326
  }
2327
+ const malformedIndex = fieldList.findIndex(
2328
+ (field) => field === null || typeof field !== "object" || Array.isArray(field)
2329
+ );
2330
+ if (malformedIndex !== -1) {
2331
+ throw new exports.QvdCorruptedError("The QVD file header declares a field with no properties", {
2332
+ fieldIndex: malformedIndex,
2333
+ fieldCount: fieldList.length,
2334
+ file: this._path,
2335
+ stage: "parseHeader"
2336
+ });
2337
+ }
1995
2338
  this._headerOffset = headerBeginIndex;
1996
2339
  this._symbolTableOffset = headerEndIndex;
1997
2340
  this._indexTableOffset = this._symbolTableOffset + parseInt(this._header["QvdTableHeader"]["Offset"], 10);
2341
+ this._allFields = fieldList;
2342
+ this._selectedFields = selectFields(this._allFields, this._requestedFields, this._path);
1998
2343
  }
1999
2344
  /**
2000
2345
  * Establishes the geometry of the index table, and validates it.
@@ -2005,14 +2350,15 @@ var init_QvdFileReader = __esm({
2005
2350
  * about keeping the sign in step with the other one: the two could drift, and #113 is what
2006
2351
  * that looks like when they do. There is one copy now.
2007
2352
  *
2008
- * @param {number|null} rowLimit Maximum rows of interest, or null for all of them.
2353
+ * @param {QvdRowWindow} window The rows of interest, as file row indices.
2009
2354
  * @param {string} stage Stage name for any error raised here.
2010
2355
  * @return {{fields: Array<any>, recordSize: number, totalRows: number, rowsToLoad: number,
2011
- * indexBuffer: Buffer}} The record geometry.
2356
+ * indexBuffer: Buffer}} The record geometry. `indexBuffer` starts at the window's first
2357
+ * record, so the decoder always counts from zero.
2012
2358
  * @private
2013
2359
  */
2014
- _planIndexTable(rowLimit, stage) {
2015
- if (!this._buffer || !this._header || !this._indexTableOffset) {
2360
+ _planIndexTable(window, stage) {
2361
+ if (!this._buffer || !this._header || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
2016
2362
  throw new exports.QvdCorruptedError(
2017
2363
  "The QVD file has not been loaded in the proper order or has not been loaded at all.",
2018
2364
  {
@@ -2021,14 +2367,12 @@ var init_QvdFileReader = __esm({
2021
2367
  }
2022
2368
  );
2023
2369
  }
2024
- let fields = this._header["QvdTableHeader"]["Fields"]["QvdFieldHeader"];
2025
- if (!Array.isArray(fields)) {
2026
- fields = [fields];
2027
- }
2370
+ const allFields = this._allFields;
2371
+ const fields = this._selectedFields;
2028
2372
  const recordSize = parseInt(this._header["QvdTableHeader"]["RecordByteSize"], 10);
2029
2373
  const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
2030
- const rowsToLoad = rowLimit !== null ? Math.min(rowLimit, totalRows) : totalRows;
2031
2374
  const indexTableLength = parseInt(this._header["QvdTableHeader"]["Length"], 10);
2375
+ const { offset: firstRow, limit: rowsToLoad } = resolveWindow(window, totalRows);
2032
2376
  validateIndexTableMetadata(
2033
2377
  recordSize,
2034
2378
  totalRows,
@@ -2037,11 +2381,20 @@ var init_QvdFileReader = __esm({
2037
2381
  this._buffer.length,
2038
2382
  rowsToLoad,
2039
2383
  this._path,
2040
- this._fileSize
2384
+ this._fileSize,
2385
+ firstRow,
2386
+ this._bufferFirstRow
2041
2387
  );
2042
- const indexBuffer = this._buffer.subarray(this._indexTableOffset, this._indexTableOffset + indexTableLength + 1);
2043
- for (const field of fields) {
2044
- validateFieldBitMetadata(field, recordSize, this._path);
2388
+ const bufferRecordStart = (firstRow - this._bufferFirstRow) * recordSize;
2389
+ const indexBuffer = this._buffer.subarray(
2390
+ this._indexTableOffset + bufferRecordStart,
2391
+ this._indexTableOffset + bufferRecordStart + rowsToLoad * recordSize
2392
+ );
2393
+ if (!this._fieldBitMetadataValidated) {
2394
+ for (const field of allFields) {
2395
+ validateFieldBitMetadata(field, recordSize, this._path);
2396
+ }
2397
+ this._fieldBitMetadataValidated = true;
2045
2398
  }
2046
2399
  assert2__default.default(
2047
2400
  rowsToLoad === 0 || recordSize === 0 || Math.floor(indexBuffer.length / recordSize) >= rowsToLoad,
@@ -2053,31 +2406,45 @@ var init_QvdFileReader = __esm({
2053
2406
  * Analyzes the index table to determine which symbols are actually needed.
2054
2407
  * This is used for two-pass symbol filtering optimization.
2055
2408
  *
2056
- * @param {number} maxRows The maximum number of rows to analyze.
2057
- * @return {Promise<Map<string, Set<number>>>} Map of field names to Set of needed symbol indices.
2409
+ * Only the selected fields are analysed. An unselected field's symbols are never parsed, so
2410
+ * there is nothing for a usage set to filter and decoding its column would be a pass over the
2411
+ * whole window for an answer nobody reads.
2412
+ *
2413
+ * @param {QvdRowWindow} window The rows to analyse.
2414
+ * @return {Promise<Array<Set<number>>>} One set of needed symbol indices per selected field, in
2415
+ * the same order `_parseSymbolTable` walks them.
2058
2416
  * @private
2059
2417
  */
2060
- async _analyzeIndexTableSymbolUsage(maxRows) {
2061
- const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(maxRows, "analyzeIndexTableSymbolUsage");
2062
- const symbolUsage = /* @__PURE__ */ new Map();
2063
- const column = new Int32Array(rowsToLoad);
2064
- fields.forEach((field) => {
2418
+ async _analyzeIndexTableSymbolUsage(window) {
2419
+ const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(window, "analyzeIndexTableSymbolUsage");
2420
+ const symbolUsage = [];
2421
+ const sliceRows = Math.min(rowsToLoad, ANALYSIS_SLICE_ROWS);
2422
+ const column = new Int32Array(sliceRows);
2423
+ fields.forEach((field, position) => {
2424
+ this._throwIfAborted();
2065
2425
  const needed = /* @__PURE__ */ new Set();
2066
- symbolUsage.set(field["FieldName"], needed);
2067
- decodeIndexColumn(
2068
- indexBuffer,
2069
- recordSize,
2070
- rowsToLoad,
2071
- parseInt(field["BitOffset"], 10),
2072
- parseInt(field["BitWidth"], 10),
2073
- parseInt(field["Bias"], 10),
2074
- column
2075
- );
2076
- for (let row = 0; row < rowsToLoad; row++) {
2077
- if (column[row] >= 0) {
2078
- needed.add(column[row]);
2426
+ symbolUsage[position] = needed;
2427
+ const bitOffset = parseInt(field["BitOffset"], 10);
2428
+ const bitWidth = parseInt(field["BitWidth"], 10);
2429
+ const bias = parseInt(field["Bias"], 10);
2430
+ for (let first = 0; first < rowsToLoad; first += sliceRows) {
2431
+ const count = Math.min(sliceRows, rowsToLoad - first);
2432
+ decodeIndexColumn(
2433
+ first === 0 ? indexBuffer : indexBuffer.subarray(first * recordSize),
2434
+ recordSize,
2435
+ count,
2436
+ bitOffset,
2437
+ bitWidth,
2438
+ bias,
2439
+ column
2440
+ );
2441
+ for (let row = 0; row < count; row++) {
2442
+ if (column[row] >= 0) {
2443
+ needed.add(column[row]);
2444
+ }
2079
2445
  }
2080
2446
  }
2447
+ this._emitProgress("symbol-analysis", position + 1, fields.length);
2081
2448
  });
2082
2449
  return symbolUsage;
2083
2450
  }
@@ -2085,12 +2452,20 @@ var init_QvdFileReader = __esm({
2085
2452
  * Parses the symbol table of the QVD file. This method is part of the parsing process
2086
2453
  * and should not be called directly.
2087
2454
  *
2088
- * @param {Map<string, Set<number>>|null} symbolsToKeep Optional map of field names to symbol indices to keep.
2089
- * If provided, only these symbols will be parsed (two-pass filtering optimization).
2090
- * @param {number|null} maxRows Optional maximum number of rows being loaded (for memory estimation).
2455
+ * A field the caller did not select is skipped whole. Its symbol area is neither scanned nor
2456
+ * parsed - the per-field `Offset` and `Length` say exactly where it is, so there is nothing to
2457
+ * walk past - and that is where field selection earns its keep. The index decode is cheap by
2458
+ * comparison; parsing symbols is not.
2459
+ *
2460
+ * @param {Array<Set<number>>|null} symbolsToKeep Optional set of symbol indices to keep per
2461
+ * selected field, indexed by position. If provided, only these symbols will be parsed
2462
+ * (two-pass filtering optimization).
2463
+ * @param {number} rowsToLoad Rows the read covers, for memory estimation.
2464
+ * @param {{rows: number, perChunk: number}|null} [liveRows=null] Rows held at one instant when
2465
+ * that is fewer than the window covers - see `_prepare`.
2091
2466
  */
2092
- async _parseSymbolTable(symbolsToKeep = null, maxRows = null) {
2093
- if (!this._buffer || !this._header || !this._symbolTableOffset || !this._indexTableOffset) {
2467
+ async _parseSymbolTable(symbolsToKeep = null, rowsToLoad = 0, liveRows = null) {
2468
+ if (!this._buffer || !this._header || !this._symbolTableOffset || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
2094
2469
  throw new exports.QvdCorruptedError(
2095
2470
  "The QVD file has not been loaded in the proper order or has not been loaded at all.",
2096
2471
  {
@@ -2099,7 +2474,8 @@ var init_QvdFileReader = __esm({
2099
2474
  }
2100
2475
  );
2101
2476
  }
2102
- let fields = this._header["QvdTableHeader"]["Fields"]["QvdFieldHeader"];
2477
+ const allFields = this._allFields;
2478
+ const fields = this._selectedFields;
2103
2479
  const symbolBuffer = this._buffer.subarray(this._symbolTableOffset, this._indexTableOffset);
2104
2480
  const symbolTableSize = symbolBuffer.length;
2105
2481
  const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
@@ -2107,32 +2483,25 @@ var init_QvdFileReader = __esm({
2107
2483
  if (this._headerMatchesFile) {
2108
2484
  validateMemoryAvailability(
2109
2485
  symbolTableSize,
2110
- maxRows,
2486
+ rowsToLoad,
2111
2487
  totalRows,
2112
2488
  this._path,
2113
2489
  this._memorySafetyFactor,
2114
- Array.isArray(fields) ? fields.length : 1,
2115
- this._materialisesRows
2490
+ fields.length,
2491
+ this._materialisesRows,
2492
+ liveRows
2116
2493
  );
2117
2494
  }
2118
- warnLargeSymbolTable(
2119
- symbolTableSize,
2120
- maxRows,
2121
- totalRows,
2122
- Array.isArray(fields) ? fields.length : 1,
2123
- this._materialisesRows
2124
- );
2125
- if (!Array.isArray(fields)) {
2126
- fields = [fields];
2127
- }
2128
- for (const field of fields) {
2495
+ warnLargeSymbolTable(symbolTableSize, rowsToLoad, totalRows, fields.length, this._materialisesRows);
2496
+ for (const field of allFields) {
2129
2497
  validateFieldMetadata(field, symbolBuffer.length, this._path);
2130
2498
  }
2131
- this._symbolTable = fields.map((field) => {
2499
+ this._symbolTable = fields.map((field, position) => {
2500
+ this._throwIfAborted();
2132
2501
  const symbolsOffset = parseInt(field["Offset"], 10);
2133
2502
  const symbolsLength = parseInt(field["Length"], 10);
2134
2503
  const fieldName = field["FieldName"];
2135
- const neededSymbols = symbolsToKeep ? symbolsToKeep.get(fieldName) : null;
2504
+ const neededSymbols = symbolsToKeep ? symbolsToKeep[position] : null;
2136
2505
  const filteringEnabled = neededSymbols !== null;
2137
2506
  const symbols = [];
2138
2507
  let symbolIndex = 0;
@@ -2152,6 +2521,7 @@ var init_QvdFileReader = __esm({
2152
2521
  pointer += bytesRead - 1;
2153
2522
  symbolIndex++;
2154
2523
  }
2524
+ this._emitProgress("symbol-table", position + 1, fields.length);
2155
2525
  return symbols;
2156
2526
  });
2157
2527
  }
@@ -2170,13 +2540,18 @@ var init_QvdFileReader = __esm({
2170
2540
  * same for every row, so they are hoisted out of the loop and the inner loop does arithmetic
2171
2541
  * into a typed array and nothing else. Rows are assembled later, once, in `load()`.
2172
2542
  *
2173
- * @param {number|null} maxRows The maximum number of rows to parse. If null, all rows are parsed.
2543
+ * The window is what makes chunked iteration cheap: `decodeIndexColumn` walks records by
2544
+ * `base += recordSize`, so decoding rows k to k+n is a question of where the buffer slice starts
2545
+ * and how many iterations run. Nothing about the decoder changed to support it.
2546
+ *
2547
+ * @param {QvdRowWindow} window The rows to decode.
2174
2548
  */
2175
- async _parseIndexTable(maxRows = null) {
2176
- const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(maxRows, "parseIndexTable");
2549
+ async _parseIndexTable(window) {
2550
+ const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(window, "parseIndexTable");
2177
2551
  this._rowsDecoded = rowsToLoad;
2178
- this._indexColumns = fields.map(
2179
- (field) => decodeIndexColumn(
2552
+ this._indexColumns = fields.map((field, position) => {
2553
+ this._throwIfAborted();
2554
+ const column = decodeIndexColumn(
2180
2555
  indexBuffer,
2181
2556
  recordSize,
2182
2557
  rowsToLoad,
@@ -2184,8 +2559,10 @@ var init_QvdFileReader = __esm({
2184
2559
  parseInt(field["BitWidth"], 10),
2185
2560
  parseInt(field["Bias"], 10),
2186
2561
  new Int32Array(rowsToLoad)
2187
- )
2188
- );
2562
+ );
2563
+ this._emitProgress("index-table", position + 1, fields.length);
2564
+ return column;
2565
+ });
2189
2566
  }
2190
2567
  /**
2191
2568
  * Reads the file's schema and header metadata, without touching the symbol or index tables.
@@ -2202,8 +2579,11 @@ var init_QvdFileReader = __esm({
2202
2579
  * @return {Promise<import('./QvdDataFrame.js').QvdFileMetadata>} The file's schema and header.
2203
2580
  */
2204
2581
  async loadMetadata() {
2205
- await this._readData(null, true);
2582
+ await this._readData({ offset: 0, limit: null }, true);
2583
+ this._emitProgress("header", 0, 1);
2206
2584
  await this._parseHeader();
2585
+ this._emitProgress("header", 1, 1);
2586
+ this._throwIfAborted();
2207
2587
  assert2__default.default(this._header, "The QVD file header has not been parsed.");
2208
2588
  const header = this._header["QvdTableHeader"];
2209
2589
  let fields = header["Fields"]?.["QvdFieldHeader"] ?? [];
@@ -2238,114 +2618,200 @@ var init_QvdFileReader = __esm({
2238
2618
  /**
2239
2619
  * Loads the QVD file into memory and parses it.
2240
2620
  *
2241
- * @param {number|null} maxRows The maximum number of rows to load. If null, all rows are loaded.
2242
- * Must be a non-negative integer when given.
2243
- * @throws {QvdValidationError} If maxRows is neither null nor a non-negative integer.
2621
+ * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [window]
2622
+ * The rows to load. A number or null means what it always meant - the first N rows, or all of
2623
+ * them - and `{offset, limit}` is the same thing said more precisely, so `5` and
2624
+ * `{offset: 0, limit: 5}` are one read. `maxRows` is accepted as a second name for `limit`.
2625
+ * @throws {QvdValidationError} If the window is not a non-negative integer, null, or a valid
2626
+ * `{offset, limit}` object.
2244
2627
  * @return {Promise<QvdDataFrame>} The loaded QVD file.
2245
2628
  */
2246
- async load(maxRows = null) {
2247
- const { columns, metadata, loadStats, resolvedByField } = await this._decode(maxRows);
2629
+ async load(window = null) {
2630
+ const rows = normaliseWindow(window, this._path);
2631
+ const prepared = await this._prepare(rows);
2632
+ await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
2633
+ const data = this._buildRows(prepared.resolvedByField, 0, prepared.rowsAvailable);
2634
+ return new exports.QvdDataFrame(data, prepared.columns, prepared.metadata, {
2635
+ ...prepared.loadStats,
2636
+ rowsLoaded: data.length
2637
+ });
2638
+ }
2639
+ /**
2640
+ * Reads the file as columns, without ever materialising rows.
2641
+ *
2642
+ * Shares every step with `load()` up to the point where rows would be built - see `_prepare`.
2643
+ * What it keeps instead is what the decoder already produced: one `Int32Array` of stored
2644
+ * indices per field, and one resolved value per distinct symbol. On the 1.7M x 20 taxi
2645
+ * fixture that is 38.6 MiB against the 352.8 MiB `data` retains, because a column costs four
2646
+ * bytes per row rather than a boxed value per cell, and the symbols are a few thousand
2647
+ * entries shared across every row that uses them.
2648
+ *
2649
+ * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [window]
2650
+ * The rows to decode, in the same spellings `load()` accepts.
2651
+ * @return {Promise<import('./QvdColumnTable.js').QvdColumnTable>} The decoded columns.
2652
+ */
2653
+ async loadColumnar(window = null) {
2654
+ const rows = normaliseWindow(window, this._path);
2655
+ const prepared = await this._prepare(rows);
2656
+ await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
2657
+ const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
2248
2658
  assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
2249
- const indexColumns = this._indexColumns;
2250
- const fieldCount = indexColumns.length;
2251
- const data = new Array(this._rowsDecoded);
2252
- for (let row = 0; row < this._rowsDecoded; row++) {
2253
- const values = new Array(fieldCount);
2254
- for (let field = 0; field < fieldCount; field++) {
2255
- const symbolIndex = indexColumns[field][row];
2256
- values[field] = symbolIndex < 0 ? null : resolvedByField[field][symbolIndex];
2257
- }
2258
- data[row] = values;
2259
- }
2260
- loadStats.rowsLoaded = data.length;
2261
- return new exports.QvdDataFrame(data, columns, metadata, loadStats);
2659
+ return new QvdColumnTable2({
2660
+ columns: prepared.columns,
2661
+ codesByField: this._indexColumns,
2662
+ symbolsByField: prepared.resolvedByField,
2663
+ rowCount: this._rowsDecoded,
2664
+ metadata: prepared.metadata,
2665
+ loadStats: { ...prepared.loadStats, rowsLoaded: this._rowsDecoded }
2666
+ });
2262
2667
  }
2263
2668
  /**
2264
- * Reads and decodes the file, stopping short of building rows.
2669
+ * Yields the window as data frames of at most `chunkSize` rows.
2265
2670
  *
2266
- * Everything `load()` and `loadColumnar()` have in common, which is everything except the
2267
- * shape of the answer. Two read paths for one binary format is the drift risk #113 is the
2268
- * standing example of - a stored index resolved one way here and another way there returns
2269
- * plausible wrong values and throws nothing - so there is one path, and the two entry points
2270
- * differ only in what they do with what it returns.
2671
+ * The file is opened, read and parsed **once**; only the index decode and the row building
2672
+ * happen per chunk. That is the whole reason this exists as a method rather than as a loop of
2673
+ * `load({offset, limit})` calls at the call site: the symbol table has to be parsed in full
2674
+ * whatever the chunk size - a stored index in the last chunk can address the first symbol -
2675
+ * and re-parsing it per chunk is what makes the obvious implementation cost more than a plain
2676
+ * load rather than less. PyQvd's chunked read does re-read it, and the comment on #140 records
2677
+ * that as a limitation rather than a design.
2271
2678
  *
2272
- * @param {number|null} maxRows Maximum rows to decode, or null for all of them.
2273
- * @return {Promise<{columns: Array<string>, metadata: any, loadStats: any,
2274
- * resolvedByField: Array<Array<any>>}>} The decoded file.
2275
- * @private
2679
+ * What it bounds is row materialisation, which is what actually dominates a large read's heap.
2680
+ * Two chunks of rows are alive at a time, not one - `for await` keeps the yielded frame
2681
+ * reachable while this generator builds the next - which is why `liveRows` below is
2682
+ * `chunkSize * 2`, and why the heap it needs is twice what one chunk suggests.
2683
+ *
2684
+ * A window covering no rows yields nothing at all, rather than one empty frame - so
2685
+ * `for await` over an exhausted offset does nothing, which is what a paging loop wants.
2686
+ *
2687
+ * @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} window
2688
+ * The rows to cover, in the same spellings `load()` accepts.
2689
+ * @param {number} chunkSize Rows per frame. Must be a positive integer.
2690
+ * @return {AsyncGenerator<QvdDataFrame>} The chunks, in order.
2276
2691
  */
2277
- async _decode(maxRows = null) {
2278
- if (maxRows !== null && (typeof maxRows !== "number" || !Number.isInteger(maxRows) || maxRows < 0)) {
2279
- throw new exports.QvdValidationError("maxRows must be a non-negative integer, or null to load all rows", {
2280
- provided: maxRows,
2281
- type: typeof maxRows,
2692
+ async *iterateRows(window, chunkSize) {
2693
+ if (typeof chunkSize !== "number" || !Number.isInteger(chunkSize) || chunkSize <= 0) {
2694
+ throw new exports.QvdValidationError("chunkSize must be a positive integer", {
2695
+ provided: chunkSize,
2696
+ type: typeof chunkSize,
2282
2697
  file: this._path
2283
2698
  });
2284
2699
  }
2285
- await this._readData(maxRows);
2700
+ const liveRows = { rows: chunkSize * 2, perChunk: 2 };
2701
+ const rows = normaliseWindow(window, this._path);
2702
+ const prepared = await this._prepare(rows, liveRows);
2703
+ for (let done = 0; done < prepared.rowsAvailable; done += chunkSize) {
2704
+ this._throwIfAborted();
2705
+ const count = Math.min(chunkSize, prepared.rowsAvailable - done);
2706
+ const offset = prepared.offset + done;
2707
+ await this._parseIndexTable({ offset, limit: count });
2708
+ const data = this._buildRows(prepared.resolvedByField, done, prepared.rowsAvailable);
2709
+ yield new exports.QvdDataFrame(data, prepared.columns, prepared.metadata, {
2710
+ ...prepared.loadStats,
2711
+ offset,
2712
+ rowsLoaded: data.length
2713
+ });
2714
+ }
2715
+ }
2716
+ /**
2717
+ * Reads the file and resolves its symbols, stopping short of decoding any rows.
2718
+ *
2719
+ * Everything `load()`, `loadColumnar()` and `iterateRows()` have in common, which is everything
2720
+ * that depends on the file rather than on the window. Two read paths for one binary format is
2721
+ * the drift risk #113 is the standing example of - a stored index resolved one way here and
2722
+ * another way there returns plausible wrong values and throws nothing - so there is one path,
2723
+ * and the entry points differ only in what they do with what it returns and how many rows they
2724
+ * ask for at a time.
2725
+ *
2726
+ * @param {QvdRowWindow} window The rows the read covers.
2727
+ * @param {{rows: number, perChunk: number}|null} [liveRows] Rows held at one instant when that
2728
+ * is fewer than the window covers, and how many of them one row of the caller's chunk size
2729
+ * accounts for. Only `iterateRows` passes it; every other read holds what it covers.
2730
+ * @return {Promise<{columns: Array<string>, metadata: any, loadStats: any,
2731
+ * resolvedByField: Array<Array<any>>, rowsAvailable: number, offset: number}>} The parsed
2732
+ * file, with the window as it resolved against it.
2733
+ * @private
2734
+ */
2735
+ async _prepare(window, liveRows = null) {
2736
+ this._throwIfAborted();
2737
+ await this._readData(window, false, liveRows);
2738
+ this._emitProgress("header", 0, 1);
2286
2739
  await this._parseHeader();
2740
+ this._emitProgress("header", 1, 1);
2741
+ this._throwIfAborted();
2742
+ assert2__default.default(this._header, "The QVD file header has not been parsed.");
2743
+ const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
2744
+ const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
2745
+ const resolved = resolveWindow(window, totalRows);
2746
+ const rowsAvailable = resolved.limit;
2287
2747
  let symbolsToKeep = null;
2288
2748
  let symbolsKept = null;
2289
- if (maxRows !== null && this._header) {
2290
- const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
2749
+ if (window.limit !== null || window.offset > 0) {
2291
2750
  if (symbolTableLength > this._symbolFilteringThreshold) {
2292
- symbolsToKeep = await this._analyzeIndexTableSymbolUsage(maxRows);
2293
- symbolsKept = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
2751
+ symbolsToKeep = await this._analyzeIndexTableSymbolUsage({ offset: resolved.offset, limit: rowsAvailable });
2752
+ symbolsKept = symbolsToKeep.reduce((sum, set) => sum + set.size, 0);
2294
2753
  }
2295
2754
  }
2296
- await this._parseSymbolTable(symbolsToKeep, maxRows);
2297
- await this._parseIndexTable(maxRows);
2298
- assert2__default.default(this._header, "The QVD file header has not been parsed.");
2755
+ await this._parseSymbolTable(symbolsToKeep, rowsAvailable, liveRows);
2299
2756
  assert2__default.default(this._symbolTable, "The QVD file symbol table has not been parsed.");
2300
- assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
2757
+ this._throwIfAborted();
2301
2758
  const resolvedByField = this._symbolTable.map((symbols) => {
2302
- const resolved = new Array(symbols.length);
2759
+ const resolved2 = new Array(symbols.length);
2303
2760
  for (let index = 0; index < symbols.length; index++) {
2304
2761
  const value = symbols[index]?.toPrimaryValue();
2305
- resolved[index] = typeof value === "string" && value.trim() !== "" && !isNaN(Number(value)) ? Number(value) : value;
2762
+ resolved2[index] = typeof value === "string" && value.trim() !== "" && !isNaN(Number(value)) ? Number(value) : value;
2306
2763
  }
2307
- return resolved;
2764
+ return resolved2;
2308
2765
  });
2309
- let fields = this._header["QvdTableHeader"]["Fields"]["QvdFieldHeader"];
2310
- if (!Array.isArray(fields)) {
2311
- fields = [fields];
2312
- }
2313
- const columns = fields.map((field) => field["FieldName"]);
2766
+ assert2__default.default(this._selectedFields, "The QVD file fields have not been resolved.");
2767
+ const columns = this._selectedFields.map((field) => field["FieldName"]);
2314
2768
  const metadata = this._header["QvdTableHeader"];
2315
2769
  const loadStats = {
2316
- symbolTableBytes: parseInt(this._header["QvdTableHeader"]["Offset"], 10),
2317
- totalRows: parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10),
2318
- rowsLoaded: this._rowsDecoded,
2770
+ symbolTableBytes: symbolTableLength,
2771
+ totalRows,
2772
+ rowsLoaded: 0,
2773
+ offset: resolved.offset,
2319
2774
  symbolFiltering: symbolsToKeep !== null,
2320
2775
  symbolsKept
2321
2776
  };
2322
- return { columns, metadata, loadStats, resolvedByField };
2777
+ return { columns, metadata, loadStats, resolvedByField, rowsAvailable, offset: resolved.offset };
2323
2778
  }
2324
2779
  /**
2325
- * Reads the file as columns, without ever materialising rows.
2780
+ * Builds rows from the columns currently decoded.
2326
2781
  *
2327
- * Shares every step with `load()` up to the point where rows would be built - see `_decode`.
2328
- * What it keeps instead is what the decoder already produced: one `Int32Array` of stored
2329
- * indices per field, and one resolved value per distinct symbol. On the 1.7M x 20 taxi
2330
- * fixture that is 38.6 MiB against the 352.8 MiB `data` retains, because a column costs four
2331
- * bytes per row rather than a boxed value per cell, and the symbols are a few thousand
2332
- * entries shared across every row that uses them.
2782
+ * `data` stays eager: of the four ways this library is used - a full read, a preview already
2783
+ * bounded by a limit, writing an array out, and reading metadata - not one is helped by
2784
+ * materialising a row only when it is touched, and a lazy accessor would cost a proxy, a cache
2785
+ * and mutation semantics to serve none of them. A caller who wants columns without paying for
2786
+ * rows uses `QvdColumnTable`, which stops before this loop.
2333
2787
  *
2334
- * @param {number|null} maxRows The maximum number of rows to decode.
2335
- * @return {Promise<import('./QvdColumnTable.js').QvdColumnTable>} The decoded columns.
2788
+ * @param {Array<Array<any>>} resolvedByField One resolved value per distinct symbol, per field.
2789
+ * @param {number} progressBase Rows already delivered before this call, so that progress over a
2790
+ * chunked iteration counts the whole window rather than restarting at every chunk.
2791
+ * @param {number} progressTotal Rows the whole window covers.
2792
+ * @return {Array<Array<any>>} The rows.
2793
+ * @private
2336
2794
  */
2337
- async loadColumnar(maxRows = null) {
2338
- const { columns, metadata, loadStats, resolvedByField } = await this._decode(maxRows);
2339
- const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
2795
+ _buildRows(resolvedByField, progressBase, progressTotal) {
2340
2796
  assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
2341
- return new QvdColumnTable2({
2342
- columns,
2343
- codesByField: this._indexColumns,
2344
- symbolsByField: resolvedByField,
2345
- rowCount: this._rowsDecoded,
2346
- metadata,
2347
- loadStats
2348
- });
2797
+ const indexColumns = this._indexColumns;
2798
+ const fieldCount = indexColumns.length;
2799
+ const rowCount = this._rowsDecoded;
2800
+ const data = new Array(rowCount);
2801
+ const reportInterval = Math.max(1, Math.floor(progressTotal / 100));
2802
+ for (let row = 0; row < rowCount; row++) {
2803
+ const values = new Array(fieldCount);
2804
+ for (let field = 0; field < fieldCount; field++) {
2805
+ const symbolIndex = indexColumns[field][row];
2806
+ values[field] = symbolIndex < 0 ? null : resolvedByField[field][symbolIndex];
2807
+ }
2808
+ data[row] = values;
2809
+ if ((progressBase + row + 1) % reportInterval === 0 || row + 1 === rowCount) {
2810
+ this._throwIfAborted();
2811
+ this._emitProgress("rows", progressBase + row + 1, progressTotal);
2812
+ }
2813
+ }
2814
+ return data;
2349
2815
  }
2350
2816
  };
2351
2817
  }
@@ -2356,6 +2822,7 @@ exports.QvdDataFrame = void 0;
2356
2822
  var init_QvdDataFrame = __esm({
2357
2823
  "src/QvdDataFrame.js"() {
2358
2824
  init_QvdErrors();
2825
+ init_readOptions();
2359
2826
  exports.QvdDataFrame = class _QvdDataFrame {
2360
2827
  /**
2361
2828
  * Represents the data frame stored inside a QVD file.
@@ -2398,12 +2865,15 @@ var init_QvdDataFrame = __esm({
2398
2865
  /**
2399
2866
  * Returns statistics about the read that produced this data frame.
2400
2867
  *
2401
- * Only a frame returned by fromQvd() carries these; fromDict(), head() and tail() produce
2402
- * frames that describe no particular read, and report null rather than a stale figure.
2868
+ * Carried by every frame that came from a file - `fromQvd()`, and each chunk `iterate()` yields,
2869
+ * which is how a chunk reports its `offset`. `fromDict()`, `head()`, `tail()`, `rows()` and
2870
+ * `select()` describe no particular read and report null rather than a stale figure.
2403
2871
  *
2404
2872
  * The main use is confirming that a lazy load actually filtered the symbol table:
2405
2873
  * `symbolFiltering` says whether the two-pass path ran, and `symbolsKept` how many symbols
2406
- * survived it, which for a small maxRows should be a tiny fraction of the file's total.
2874
+ * survived it. Note that a bounded read does not filter on its own - the two-pass path engages
2875
+ * only above `symbolFilteringThreshold`, so on a file below it this reports false and every
2876
+ * symbol was parsed however few rows were asked for.
2407
2877
  *
2408
2878
  * @return {QvdLoadStats|null} Load statistics, or null if this frame did not come from a file.
2409
2879
  */
@@ -2780,6 +3250,18 @@ var init_QvdDataFrame = __esm({
2780
3250
  * @param {Object} [options] Optional loading options.
2781
3251
  * @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
2782
3252
  * integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
3253
+ * This is the older name for `limit`; the two are the same option and passing both throws.
3254
+ * @param {number|null} [options.limit] Rows to read, counting from `offset`. The same number as
3255
+ * `maxRows`, spelled so that it reads correctly beside an offset.
3256
+ * @param {number} [options.offset=0] File row to start at. An offset past the end of the file
3257
+ * returns no rows rather than throwing, so a paging loop terminates on its own.
3258
+ * @param {Array<string>|null} [options.fields] Field names to read, in the order they should
3259
+ * appear in the result. Unselected fields have their symbols skipped entirely rather than
3260
+ * parsed and discarded. An unknown or repeated name throws.
3261
+ * @param {Function} [options.onProgress] Called with `{stage, current, total, percent}` as the
3262
+ * read proceeds - the same shape `toQvd`'s callback receives.
3263
+ * @param {AbortSignal} [options.signal] Cancels the read. The rejection is `signal.reason`,
3264
+ * which is a `DOMException` named `AbortError` unless you aborted with a reason of your own.
2783
3265
  * @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
2784
3266
  * must be within this directory, with symlinks resolved first, so a link inside it that points
2785
3267
  * outside it is rejected. Defaults to the current working directory. To permit an entire
@@ -2792,17 +3274,42 @@ var init_QvdDataFrame = __esm({
2792
3274
  * **Zero disables the memory check entirely.**
2793
3275
  * @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes, above which
2794
3276
  * a lazy load switches to the two-pass filtering path. Defaults to 50MB.
2795
- * @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
3277
+ * @throws {QvdValidationError} If a window option is not a non-negative integer, if both
3278
+ * `maxRows` and `limit` are given, or if `fields` names a column the file does not have.
2796
3279
  * @return {Promise<QvdDataFrame>} The data frame of the QVD file.
2797
3280
  */
2798
3281
  static async fromQvd(path3, options = {}) {
2799
3282
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2800
- const readerOptions = {
2801
- allowedDir: options.allowedDir,
2802
- memorySafetyFactor: options.memorySafetyFactor,
2803
- symbolFilteringThreshold: options.symbolFilteringThreshold
2804
- };
2805
- return await new QvdFileReader2(path3, readerOptions).load(options.maxRows !== void 0 ? options.maxRows : null);
3283
+ return await new QvdFileReader2(path3, readerOptionsFrom(options)).load(windowFrom(options));
3284
+ }
3285
+ /**
3286
+ * Reads a QVD file in chunks, as an async generator of data frames.
3287
+ *
3288
+ * The file is opened, read and parsed once; only the index decode and the row building happen
3289
+ * per chunk, so what this bounds is row materialisation - the part that actually dominates a
3290
+ * large read's heap. It is **not** constant-memory reading of an arbitrarily large file: the
3291
+ * symbol table is parsed in full whatever the chunk size, because a stored index in the last
3292
+ * chunk can address the first symbol. On a high-cardinality file that table is the bulk of the
3293
+ * cost, and `readMetadata` is the only read that avoids it.
3294
+ *
3295
+ * ```js
3296
+ * for await (const chunk of QvdDataFrame.iterate('big.qvd', {chunkSize: 50_000})) {
3297
+ * process(chunk.data);
3298
+ * }
3299
+ * ```
3300
+ *
3301
+ * A window covering no rows yields nothing, so a loop over an exhausted offset simply does not
3302
+ * run its body.
3303
+ *
3304
+ * @param {string} path The path to the QVD file.
3305
+ * @param {Object} [options] The same options `fromQvd` takes, plus:
3306
+ * @param {number} [options.chunkSize=100000] Rows per frame. Must be a positive integer.
3307
+ * @return {AsyncGenerator<QvdDataFrame>} The chunks, in file order.
3308
+ */
3309
+ static async *iterate(path3, options = {}) {
3310
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
3311
+ const reader = new QvdFileReader2(path3, readerOptionsFrom(options));
3312
+ yield* reader.iterateRows(windowFrom(options), options.chunkSize === void 0 ? 1e5 : options.chunkSize);
2806
3313
  }
2807
3314
  /**
2808
3315
  * Reads a QVD file's schema and header metadata, without reading its data.
@@ -2826,11 +3333,15 @@ var init_QvdDataFrame = __esm({
2826
3333
  * @param {Object} [options] Optional reading options.
2827
3334
  * @param {string} [options.allowedDir] Optional allowed directory path, applied exactly as it
2828
3335
  * is for `fromQvd`.
3336
+ * @param {Function} [options.onProgress] Called with `{stage, current, total, percent}`, as on
3337
+ * the reads that return data. Only the `read` and `header` stages occur here; there are no
3338
+ * symbols to parse and no rows to build.
3339
+ * @param {AbortSignal} [options.signal] Cancels the read, rejecting with `signal.reason`.
2829
3340
  * @return {Promise<QvdFileMetadata>} The file's schema and header metadata.
2830
3341
  */
2831
3342
  static async readMetadata(path3, options = {}) {
2832
3343
  const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
2833
- return await new QvdFileReader2(path3, { allowedDir: options.allowedDir }).loadMetadata();
3344
+ return await new QvdFileReader2(path3, metadataOptionsFrom(options)).loadMetadata();
2834
3345
  }
2835
3346
  /**
2836
3347
  * Constructs a data frame from a dictionary.