@gscdump/engine 7.3.2 → 7.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,146 @@ different publications produce different bytes. It must retain and replay the
30
30
  exact encoded bytes for retries; this option does not provide publication or
31
31
  recovery coordination itself.
32
32
 
33
+ For a bounded, already ordered source, `/hyparquet` also exports
34
+ `encodeParquetRowBatches(batches, options, write)`. It consumes sync or async
35
+ batches, coerces the declared `columns` with the same physical types as the
36
+ array encoder, and emits one Parquet file through an awaited byte callback.
37
+ It does not sort, deduplicate, retry, or publish an object. The callback borrows
38
+ its bytes until its promise settles; copy a chunk if retaining it. The caller
39
+ must abort/discard the entire destination on any error, including an error
40
+ while writing the footer. Reopening the same immutable source with identical
41
+ options produces identical bytes regardless of producer batch boundaries.
42
+ This supports a counting/digest pass followed by a separately coordinated
43
+ fixed-length upload, without the codec owning transport or commit semantics.
44
+
45
+ `decodeParquetRowGroups({ byteLength, slice }, options)` reads exact byte ranges
46
+ and by default yields one physical row group at a time. The caller must pin every range
47
+ to the same immutable object or ETag. It preserves the existing date, column
48
+ projection, row-window and filter behavior. An absent, corrupt, oversized or
49
+ incompletely read file throws; only valid empty Parquet yields no rows.
50
+
51
+ The range reader alone accepts an optional `indexedRowWindow` policy:
52
+
53
+ ```ts
54
+ decodeParquetRowGroups(pinnedObject, {
55
+ indexedRowWindow: {
56
+ targetRows: 1000,
57
+ requiredIndexedColumns: ['query', 'url'],
58
+ rangeCacheBytes: 2 * 1024 * 1024,
59
+ },
60
+ })
61
+ ```
62
+
63
+ Each group uses windows only when all designated columns are present, selected,
64
+ have usable offset-index descriptors, and have no dictionary encoding. The
65
+ schema must contain only flat scalar/UTF8/date fields; binary, nested and other
66
+ unsupported schemas retain the original group read. Ineligible groups use the
67
+ original uncached path, so `targetRows` is a target rather than a hard batch
68
+ limit. Include every potentially large column in `requiredIndexedColumns`.
69
+ All existing physical-group admission limits still apply.
70
+
71
+ Before emitting an eligible group's first window, the reader checks index
72
+ structure for projected columns, or all indexed columns when a filter may need
73
+ unprojected fields: row zero, increasing row positions, ordered nonoverlapping
74
+ page ranges and physical column bounds. A failure throws without falling back
75
+ after partial output. This assumes metadata row offsets agree with physical
76
+ page contents; it does not independently verify page-header row counts or
77
+ detect every deliberately consistent but false index.
78
+
79
+ The optional range cache defaults to zero, allows at most 2 MiB of payload and
80
+ 128 entries, and lives only through one eligible group. It is cleared on
81
+ completion, error or iterator return; another pass rereads the pinned object.
82
+ It does not cache decoded rows or provide publication/ETag coordination. Windows
83
+ can otherwise reread overlapping pages and substantially increase range I/O.
84
+ They still decode whole boundary pages, and dictionaries in other columns can
85
+ still allocate in full. These options do not bound decompressed page sizes or
86
+ the total heap; the unchanged fallback also retains its existing memory costs.
87
+ Buffered decoders, encoders and all omitted-option behavior remain unchanged.
88
+
89
+ The range reader can also opt into `limits.maxReadAndDecompressBytes`. This is a
90
+ **cumulative buffer-work budget for each physical group**, shared across all its
91
+ indexed windows and concurrent column reads. It charges requested source-range
92
+ bytes before dispatch and SNAPPY output bytes before allocating the fixed-size
93
+ output buffer. Cache hits do not allocate another range, while repeated
94
+ decompression still consumes budget. Group indexes are included; the initial
95
+ footer has its separate metadata limit. Raw/uncompressed pages and V2 levels
96
+ are covered by the range charge, so compressed data is deliberately counted
97
+ twice. Earlier charges are never refunded, including after a failed read. Failure
98
+ or iterator return closes the budget, preventing new work and rejecting late
99
+ range results. It cannot physically cancel already dispatched reads: the range
100
+ source interface has no cancellation hook.
101
+
102
+ ZSTD decompression refuses under this option: the current allocating decoder
103
+ can allocate from its frame header before checking the Parquet output size.
104
+ Without the option, ZSTD and all existing decoding behavior remain unchanged.
105
+ The budget does not measure JavaScript strings, expanded values, row containers,
106
+ internal prefetch or dictionary/page stitching copies, codec working state or
107
+ caller-retained batches. It is
108
+ not a total heap bound or protection against all malformed Parquet contents.
109
+ It can reject valid files that previously decoded, particularly old files whose
110
+ writer understated uncompressed footer sizes. Callers must preserve those files
111
+ and choose their own explicit retry/repair policy; refusal is never empty data.
112
+
113
+ `decodeParquetRowGroups(source, { boundedValues: {} })` selects a separate strict
114
+ value-cursor path. It supports flat REQUIRED/OPTIONAL scalar columns, explicitly
115
+ annotated UTF8/STRING, PLAIN and dictionary data pages (V1/V2), and SNAPPY or
116
+ UNCOMPRESSED data. Projection, physical row ranges, nulls, date normalization and
117
+ row order are preserved. Nested/binary/other logical types, other codecs or value
118
+ encodings, BOOLEAN dictionaries, filters and `indexedRowWindow` combinations
119
+ refuse; this mode never silently falls back to the eager reader. All selected
120
+ page headers are inspected before the first output to detect unsupported later
121
+ pages. Corrupt value bodies or over-limit individual values may fail after an
122
+ earlier batch; consumers must finish validating their entire input before
123
+ publishing a replacement.
124
+ Explicit row bounds in this strict mode must be nonnegative safe integers, with
125
+ positive Infinity also accepted for an open-ended `rowEnd`. Omitted bounds use
126
+ the full input, and reversed or zero-length ranges are empty.
127
+
128
+ The cursor retains at most one current data page and one encoded dictionary per
129
+ selected column. UTF8 strings, definition levels and dictionary IDs are consumed
130
+ incrementally. Dictionaries retain encoded bytes and a four-byte offset per
131
+ entry, rather than an array of decoded strings. Final bit-packed padding is
132
+ validated but not expanded. These options have independent meanings:
133
+
134
+ | `boundedValues` option | Default | Admission rule |
135
+ | --- | --- | --- |
136
+ | `targetRows` | 1000 | Hard emitted batch row limit |
137
+ | `maxBatchBytes` | 1 MiB | Before materialization: 128 per row, 16 per cell slot, 64 per cell plus twice UTF8 byte length or 32 for a fixed scalar |
138
+ | `maxValueBytes` | 256 KiB | Encoded bytes of one string, before decoding |
139
+ | `maxActiveBytes` | 24 MiB | Per group: sum of each column's largest compressed + uncompressed data page, dictionary compressed + uncompressed bytes, dictionary offset tables, plus one maximum header probe |
140
+ | `maxPages` | 16384 | Retained page-descriptor count across the selected input |
141
+ | `maxHeaderBytes` | 64 KiB | Header probe cap; starts at 512 bytes and grows only on truncation |
142
+ | `maxDictionaryEntries` | 100000 | Entry count for each encoded dictionary |
143
+
144
+ Active-buffer admission uses real page headers, not the often understated
145
+ uncompressed sizes in legacy writer footers. Page transitions release the old
146
+ cursor before reading/decompressing its successor; columns load sequentially.
147
+ Batch bytes are a conservative value-materialization charge, **not a measurement
148
+ of V8 object overhead or an isolate heap guarantee**. The footer (separately
149
+ bounded), page-descriptor objects, fixed cursor/schema state, caller-retained
150
+ batches, codec/runtime overhead and concurrent application work remain separate.
151
+ Setting larger limits deliberately permits a larger working set. A single page
152
+ still decompresses in full, and a consumer that retains all emitted batches
153
+ removes the benefit. This mode does not make arbitrary malicious files safe.
154
+
155
+ The existing `maxReadAndDecompressBytes` option also applies: a group's header
156
+ probes, physical reads and SNAPPY output share one cumulative budget. Headers
157
+ are scanned once; immutable/pinned object bytes are required across that scan
158
+ and later reads. Iterator return/error releases cursor references; already
159
+ dispatched source reads cannot be physically aborted. Defaults, the buffered
160
+ decoder and indexed-window eligibility/fallback are unchanged.
161
+
162
+ Both APIs have configurable admission limits. The encoder bounds producer
163
+ batch rows/value bytes, output group rows/value bytes, retained descriptor counts
164
+ and scalar bytes (including statistics), and the current encoded group/footer
165
+ buffer. The decoder checks footer size,
166
+ descriptor count, declared group rows/uncompressed bytes, and every range request.
167
+ The default decoder accepts the existing encoder's 25,000-row groups. These
168
+ limits do not establish a Worker heap guarantee: caller-retained rows, codec
169
+ transient allocations, footer descriptors, and concurrent requests still use
170
+ memory. Metadata limits are not protection against malicious Parquet contents.
171
+ The existing array APIs and their default bytes are unchanged.
172
+
33
173
  ## Subpath exports
34
174
 
35
175
  | Subpath | Purpose |
@@ -1,7 +1,8 @@
1
1
  import { ParquetQueryFilter } from "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/types/index.mjs";
2
2
  import "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/types/node.mjs";
3
- import { CodecCtx, DataSource, ParquetCodec, Row, TableName } from "../storage.mjs";
3
+ import { CodecCtx, DataSource, ParquetCodec, Row, TableName, WriteResult } from "../storage.mjs";
4
4
  import { ColumnDef } from "../schema.mjs";
5
+ import { BoundedValuesOptions } from "./parquet-bounded-values.mjs";
5
6
  export interface EncodeParquetOptions {
6
7
  /** File footer metadata, without adding fact columns. Keys are encoded in sorted order. */
7
8
  fileMetadata?: Readonly<Record<string, string>>;
@@ -23,6 +24,36 @@ export interface EncodeFlexOptions extends EncodeParquetOptions {
23
24
  * merges cleanly with fact-table reads.
24
25
  */
25
26
  export declare function encodeRowsToParquetFlex(rows: readonly Row[], opts: EncodeFlexOptions): Uint8Array;
27
+ export interface EncodeParquetBatchesOptions extends EncodeParquetOptions {
28
+ /** Output schema and column order. Input rows must already be ordered/deduplicated. */
29
+ columns: readonly ColumnDef[];
30
+ /** Maximum rows per output group. Default 25000, matching the array codec. */
31
+ rowGroupSize?: number;
32
+ limits?: {
33
+ /** Refuse a producer batch with more rows. Default 25000. */
34
+ maxBatchRows?: number;
35
+ /** Conservative scalar-value bytes per producer batch. Default 8 MiB. */
36
+ maxBatchBytes?: number;
37
+ /** Split groups at this conservative scalar-value budget. Default 2 MiB. */
38
+ maxRowGroupBytes?: number;
39
+ /** Bound retained footer/index descriptors. Default 4096. */
40
+ maxRowGroups?: number;
41
+ /** Bound retained descriptor/scalar bytes, including statistics. Default 8 MiB. */
42
+ maxRetainedMetadataBytes?: number;
43
+ /** Bound the current encoded group or footer buffer. Default 8 MiB. */
44
+ maxOutputBufferBytes?: number;
45
+ };
46
+ }
47
+ /**
48
+ * Encode ordered batches into ONE Parquet file with sequential backpressure.
49
+ * Group boundaries depend on rows/options, never producer batch boundaries.
50
+ * `write` borrows its bytes only until its returned promise settles; copy them
51
+ * if retaining them. This function does not close/commit a destination. On ANY
52
+ * rejection the caller must abort/discard the entire partial output, including
53
+ * after a footer callback failure. It performs no sorting, dedupe or retries.
54
+ * Memory includes one input batch, one group, and bounded footer descriptors.
55
+ */
56
+ export declare function encodeParquetRowBatches(batches: AsyncIterable<readonly Row[]> | Iterable<readonly Row[]>, opts: EncodeParquetBatchesOptions, write: (chunk: Uint8Array) => Promise<void>): Promise<WriteResult>;
26
57
  export interface DecodeParquetOptions {
27
58
  /** First physical row to decode, inclusive. */
28
59
  rowStart?: number;
@@ -49,6 +80,58 @@ export interface DecodeParquetOptions {
49
80
  columns?: readonly string[];
50
81
  }
51
82
  export declare function decodeParquetToRows(bytes: Uint8Array, opts?: DecodeParquetOptions): Promise<Row[]>;
83
+ /** A caller-owned, immutable/pinned object; ranges must return exactly the requested bytes. */
84
+ export interface ParquetRangeSource {
85
+ byteLength: number;
86
+ slice: (start: number, end?: number) => ArrayBuffer | Promise<ArrayBuffer>;
87
+ }
88
+ export interface DecodeParquetGroupsOptions extends DecodeParquetOptions {
89
+ /**
90
+ * Strict opt-in value cursors for flat scalar PLAIN/dictionary SNAPPY or raw
91
+ * pages. Rejects unsupported inputs instead of falling back to eager decoding.
92
+ * Page buffers and emitted values have separate bounds; not an isolate heap cap.
93
+ * Cannot be combined with filters or indexedRowWindow.
94
+ */
95
+ boundedValues?: BoundedValuesOptions;
96
+ /**
97
+ * Opt-in windows for flat scalar groups whose designated, selected columns
98
+ * have offset indexes and no dictionary encoding. Ineligible groups retain
99
+ * the original physical-group read, so targetRows is not a hard batch limit.
100
+ * Windows still decode complete overlapping pages, not a bounded heap size.
101
+ */
102
+ indexedRowWindow?: {
103
+ targetRows: number;
104
+ requiredIndexedColumns: readonly string[];
105
+ /** Per eligible group; 0 disables caching. Default 0, maximum 2 MiB/128 entries. */
106
+ rangeCacheBytes?: number;
107
+ };
108
+ limits?: {
109
+ /** Footer bytes before deserialization. Default 1 MiB. */
110
+ maxMetadataBytes?: number;
111
+ /** Includes the existing encoder's 25000-row groups. Default 25000. */
112
+ maxRowGroupRows?: number;
113
+ /** Declared uncompressed bytes across all columns in one group. Default 16 MiB. */
114
+ maxRowGroupBytes?: number;
115
+ /** Bound metadata descriptors. Default 4096. */
116
+ maxRowGroups?: number;
117
+ /** Maximum individual range request. Default 16 MiB. */
118
+ maxReadBytes?: number;
119
+ /**
120
+ * Optional cumulative source-range + SNAPPY-output bytes per physical group,
121
+ * including all indexed windows. Charged before reads/output allocation;
122
+ * cache hits do not allocate another range. Deliberately conservative, not
123
+ * total JS heap. ZSTD refuses only with this option. Omission is unchanged.
124
+ */
125
+ maxReadAndDecompressBytes?: number;
126
+ };
127
+ }
128
+ /**
129
+ * Pull one physical group at a time without downloading the complete object.
130
+ * The limits are admission bounds over Parquet metadata, not a memory-safety
131
+ * guarantee for malicious files or caller-retained batches. No cross-group
132
+ * read-ahead is started. Missing/corrupt/range-error input throws; it is not empty.
133
+ */
134
+ export declare function decodeParquetRowGroups(file: ParquetRangeSource, opts?: DecodeParquetGroupsOptions): AsyncGenerator<Row[]>;
52
135
  export interface HyparquetCodecOptions {
53
136
  /**
54
137
  * Override `readRows`. Useful when reads should be delegated to a faster
@@ -1,12 +1,17 @@
1
1
  import { TABLE_METADATA } from "../drizzle-schema.mjs";
2
2
  import { SCHEMAS } from "../schema.mjs";
3
3
  import { decompress } from "../node_modules/.pnpm/fzstd@0.1.1/node_modules/fzstd/esm/index.mjs";
4
- import { parquetMetadataAsync, parquetSchema } from "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/src/metadata.mjs";
4
+ import { parquetMetadata, parquetMetadataAsync, parquetSchema } from "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/src/metadata.mjs";
5
+ import { readOffsetIndex } from "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/src/indexes.mjs";
5
6
  import { parquetReadObjects } from "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/src/read.mjs";
6
7
  import "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/src/node.mjs";
7
8
  import { ByteWriter } from "../node_modules/.pnpm/hyparquet-writer@0.16.10_patch_hash_d26d002aede61ad95dd633d6557b56897cdcbc45dfc3a5d93fa0e9075ad5425f/node_modules/hyparquet-writer/src/bytewriter.mjs";
9
+ import { ParquetWriter } from "../node_modules/.pnpm/hyparquet-writer@0.16.10_patch_hash_d26d002aede61ad95dd633d6557b56897cdcbc45dfc3a5d93fa0e9075ad5425f/node_modules/hyparquet-writer/src/parquet-writer.mjs";
8
10
  import { parquetWriteRows } from "../node_modules/.pnpm/hyparquet-writer@0.16.10_patch_hash_d26d002aede61ad95dd633d6557b56897cdcbc45dfc3a5d93fa0e9075ad5425f/node_modules/hyparquet-writer/src/write-rows.mjs";
9
11
  import "../node_modules/.pnpm/hyparquet-writer@0.16.10_patch_hash_d26d002aede61ad95dd633d6557b56897cdcbc45dfc3a5d93fa0e9075ad5425f/node_modules/hyparquet-writer/src/node.mjs";
12
+ import { parquetDecodeBudget } from "./parquet-decode-budget.mjs";
13
+ import { boundedValuesPolicy, decodeBoundedValues } from "./parquet-bounded-values.mjs";
14
+ import { parquetRangeCache } from "./parquet-range-cache.mjs";
10
15
  const ROW_GROUP_SIZE = 25e3;
11
16
  const DEFAULT_COMPACTION_READ_CONCURRENCY = 8;
12
17
  const MAX_COMPACTION_READ_CONCURRENCY = 32;
@@ -206,6 +211,139 @@ function encodeRowsToParquetFlex(rows, opts) {
206
211
  return 0;
207
212
  }), columns, rowGroupSize, opts.fileMetadata);
208
213
  }
214
+ function streamLimit(value, fallback, name) {
215
+ const limit = value ?? fallback;
216
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new RangeError(`parquet stream ${name} must be a positive safe integer`);
217
+ return limit;
218
+ }
219
+ function scalarBytes(value) {
220
+ return typeof value === "string" ? value.length * 3 + 8 : 16;
221
+ }
222
+ function retainedDescriptorBytes(value, remaining) {
223
+ const seen = /* @__PURE__ */ new Set();
224
+ let bytes = 0;
225
+ const visit = (value) => {
226
+ if (value !== null && typeof value === "object") {
227
+ if (seen.has(value)) return;
228
+ seen.add(value);
229
+ bytes += 64;
230
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) bytes += value.byteLength;
231
+ else for (const key in value) if (Object.hasOwn(value, key)) {
232
+ bytes += scalarBytes(key);
233
+ visit(value[key]);
234
+ }
235
+ } else bytes += scalarBytes(value);
236
+ if (bytes > remaining) throw new RangeError("parquet stream exceeds maxRetainedMetadataBytes");
237
+ };
238
+ visit(value);
239
+ return bytes;
240
+ }
241
+ async function encodeParquetRowBatches(batches, opts, write) {
242
+ const rowGroupSize = streamLimit(opts.rowGroupSize, ROW_GROUP_SIZE, "rowGroupSize");
243
+ const maxBatchRows = streamLimit(opts.limits?.maxBatchRows, ROW_GROUP_SIZE, "maxBatchRows");
244
+ const maxBatchBytes = streamLimit(opts.limits?.maxBatchBytes, 8388608, "maxBatchBytes");
245
+ const maxRowGroupBytes = streamLimit(opts.limits?.maxRowGroupBytes, 2097152, "maxRowGroupBytes");
246
+ const maxRowGroups = streamLimit(opts.limits?.maxRowGroups, 4096, "maxRowGroups");
247
+ const maxRetainedMetadataBytes = streamLimit(opts.limits?.maxRetainedMetadataBytes, 8388608, "maxRetainedMetadataBytes");
248
+ const maxOutputBufferBytes = streamLimit(opts.limits?.maxOutputBufferBytes, 8388608, "maxOutputBufferBytes");
249
+ if (opts.columns.length === 0 || opts.columns.length > 64 || new Set(opts.columns.map((col) => col.name)).size !== opts.columns.length) throw new RangeError("parquet stream requires 1 to 64 uniquely named columns");
250
+ const codecs = opts.columns.map((col) => ({
251
+ name: col.name,
252
+ isDate: col.type === "DATE",
253
+ type: basicTypeFor(col.type),
254
+ nullable: col.nullable
255
+ }));
256
+ const schema = buildWriteSchema(opts.columns);
257
+ const metadataKeys = Object.keys(opts.fileMetadata ?? {}).sort();
258
+ let metadataBytes = 0;
259
+ for (const key of metadataKeys) {
260
+ metadataBytes += scalarBytes(key) + scalarBytes(opts.fileMetadata[key]);
261
+ if (metadataBytes > maxOutputBufferBytes) throw new RangeError("parquet stream file metadata exceeds maxOutputBufferBytes");
262
+ }
263
+ let retainedMetadataBytes = retainedDescriptorBytes([schema, opts.fileMetadata], maxRetainedMetadataBytes);
264
+ const writer = new ByteWriter(Math.min(65536, maxOutputBufferBytes));
265
+ writer.ensure = (size) => {
266
+ const required = writer.index + size;
267
+ if (required > maxOutputBufferBytes) throw new RangeError("parquet stream encoded group/footer exceeds maxOutputBufferBytes");
268
+ if (required > writer.buffer.byteLength) {
269
+ const next = new ArrayBuffer(Math.min(maxOutputBufferBytes, Math.max(required, writer.buffer.byteLength * 2)));
270
+ new Uint8Array(next).set(new Uint8Array(writer.buffer, 0, writer.index));
271
+ writer.buffer = next;
272
+ writer.view = new DataView(next);
273
+ }
274
+ };
275
+ const flush = async () => {
276
+ if (writer.index > 0) await write(new Uint8Array(writer.buffer, 0, writer.index));
277
+ writer.index = 0;
278
+ };
279
+ const output = Object.assign(writer, {
280
+ flush,
281
+ finish: flush
282
+ });
283
+ const parquet = new ParquetWriter({
284
+ writer: output,
285
+ schema,
286
+ kvMetadata: metadataKeys.length ? metadataKeys.map((key) => ({
287
+ key,
288
+ value: opts.fileMetadata[key]
289
+ })) : void 0
290
+ });
291
+ let values = codecs.map(() => []);
292
+ let groupRows = 0;
293
+ let groupBytes = 0;
294
+ let groups = 0;
295
+ let rowCount = 0;
296
+ const flushGroup = async () => {
297
+ if (!groupRows) return;
298
+ if (++groups > maxRowGroups) throw new RangeError("parquet stream exceeds maxRowGroups");
299
+ const firstIndex = parquet.pendingIndexes.length;
300
+ await parquet.write({
301
+ columnData: codecs.map((col, index) => ({
302
+ name: col.name,
303
+ nullable: col.nullable,
304
+ columnIndex: true,
305
+ data: values[index]
306
+ })),
307
+ rowGroupSize: groupRows
308
+ });
309
+ retainedMetadataBytes += retainedDescriptorBytes([parquet.row_groups[parquet.row_groups.length - 1], parquet.pendingIndexes.slice(firstIndex)], maxRetainedMetadataBytes - retainedMetadataBytes);
310
+ values = codecs.map(() => []);
311
+ groupRows = 0;
312
+ groupBytes = 0;
313
+ };
314
+ try {
315
+ for await (const batch of batches) {
316
+ if (batch.length > maxBatchRows) throw new RangeError("parquet stream exceeds maxBatchRows");
317
+ let batchBytes = 0;
318
+ for (const row of batch) {
319
+ const converted = codecs.map((col) => col.isDate ? toEpochDays(row[col.name]) : coerceValue(row[col.name], col.type));
320
+ const bytes = converted.reduce((sum, value) => sum + scalarBytes(value), 0);
321
+ batchBytes += bytes;
322
+ if (batchBytes > maxBatchBytes) throw new RangeError("parquet stream exceeds maxBatchBytes");
323
+ if (bytes > maxRowGroupBytes) throw new RangeError("parquet stream single row exceeds maxRowGroupBytes");
324
+ if (groupRows && (groupRows >= rowGroupSize || groupBytes + bytes > maxRowGroupBytes)) await flushGroup();
325
+ for (let index = 0; index < converted.length; index++) values[index].push(converted[index]);
326
+ groupRows++;
327
+ groupBytes += bytes;
328
+ rowCount++;
329
+ if (groupRows === rowGroupSize) await flushGroup();
330
+ }
331
+ }
332
+ await flushGroup();
333
+ await parquet.finish();
334
+ return {
335
+ bytes: writer.offset,
336
+ rowCount
337
+ };
338
+ } finally {
339
+ values = [];
340
+ parquet.row_groups.length = 0;
341
+ parquet.pendingIndexes.length = 0;
342
+ writer.buffer = /* @__PURE__ */ new ArrayBuffer(0);
343
+ writer.view = new DataView(writer.buffer);
344
+ writer.index = 0;
345
+ }
346
+ }
209
347
  function asyncBufferFromBytes(bytes) {
210
348
  const base = bytes.byteOffset;
211
349
  const buf = bytes.buffer;
@@ -226,7 +364,150 @@ async function decodeParquetToRows(bytes, opts = {}) {
226
364
  async function* decodeParquetGroups(bytes, opts = {}) {
227
365
  if (bytes.byteLength === 0) return;
228
366
  const file = asyncBufferFromBytes(bytes);
229
- const metadata = await parquetMetadataAsync(file);
367
+ yield* decodeGroupsFromMetadata(file, await parquetMetadataAsync(file), opts);
368
+ }
369
+ async function* decodeParquetRowGroups(file, opts = {}) {
370
+ const values = boundedValuesPolicy(opts.boundedValues);
371
+ const selection = values ? {
372
+ columns: opts.columns?.slice(),
373
+ rowStart: opts.rowStart,
374
+ rowEnd: opts.rowEnd
375
+ } : void 0;
376
+ if (values) for (const key of ["rowStart", "rowEnd"]) {
377
+ const value = selection[key];
378
+ if (value !== void 0 && !(key === "rowEnd" && value === Infinity) && (!Number.isSafeInteger(value) || value < 0)) throw new RangeError(`invalid boundedValues.${key}`);
379
+ }
380
+ if (values && (opts.filter || opts.indexedRowWindow)) throw new Error("boundedValues cannot be combined with filter or indexedRowWindow");
381
+ const indexedWindow = indexedWindowPolicy(opts.indexedRowWindow);
382
+ const maxReadAndDecompressBytes = opts.limits?.maxReadAndDecompressBytes === void 0 ? void 0 : streamLimit(opts.limits.maxReadAndDecompressBytes, 0, "maxReadAndDecompressBytes");
383
+ const maxMetadataBytes = streamLimit(opts.limits?.maxMetadataBytes, 1048576, "maxMetadataBytes");
384
+ const maxRowGroupRows = streamLimit(opts.limits?.maxRowGroupRows, ROW_GROUP_SIZE, "maxRowGroupRows");
385
+ const maxRowGroupBytes = streamLimit(opts.limits?.maxRowGroupBytes, 16777216, "maxRowGroupBytes");
386
+ const maxRowGroups = streamLimit(opts.limits?.maxRowGroups, 4096, "maxRowGroups");
387
+ const maxReadBytes = streamLimit(opts.limits?.maxReadBytes, 16777216, "maxReadBytes");
388
+ if (!Number.isSafeInteger(file.byteLength) || file.byteLength < 12) throw new RangeError("parquet stream requires a valid file length");
389
+ const bounded = {
390
+ byteLength: file.byteLength,
391
+ async slice(start, end = file.byteLength) {
392
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > file.byteLength || end - start > maxReadBytes) throw new RangeError("parquet stream range exceeds maxReadBytes or file bounds");
393
+ const bytes = await file.slice(start, end);
394
+ if (bytes.byteLength !== end - start) throw new Error("parquet stream returned an incomplete range");
395
+ return bytes;
396
+ }
397
+ };
398
+ const trailer = new DataView(await bounded.slice(file.byteLength - 8, file.byteLength));
399
+ if (trailer.getUint32(4, true) !== 827474256) throw new Error("parquet stream invalid footer");
400
+ const metadataBytes = trailer.getUint32(0, true);
401
+ if (metadataBytes > maxMetadataBytes || metadataBytes + 12 > file.byteLength) throw new RangeError("parquet stream footer exceeds maxMetadataBytes or file bounds");
402
+ const metadata = parquetMetadata(await bounded.slice(file.byteLength - metadataBytes - 8, file.byteLength));
403
+ if (metadata.row_groups.length > maxRowGroups || metadata.schema.length > 65) throw new RangeError("parquet stream exceeds row-group/schema descriptor limits");
404
+ let totalRows = 0;
405
+ for (const group of metadata.row_groups) {
406
+ if (group.columns.length > 64) throw new RangeError("parquet stream exceeds column descriptor limits");
407
+ const rows = Number(group.num_rows);
408
+ if (group.columns.some((col) => [col.meta_data?.total_uncompressed_size, col.meta_data?.total_compressed_size].some((size) => !Number.isSafeInteger(Number(size)) || Number(size) < 0))) throw new RangeError("parquet stream invalid declared group bytes");
409
+ const uncompressed = group.columns.reduce((sum, col) => sum + Number(col.meta_data?.total_uncompressed_size ?? NaN), 0);
410
+ const compressed = group.columns.reduce((sum, col) => sum + Number(col.meta_data?.total_compressed_size ?? NaN), 0);
411
+ if (!Number.isSafeInteger(rows) || rows < 0 || rows > maxRowGroupRows) throw new RangeError("parquet stream exceeds maxRowGroupRows");
412
+ if (!Number.isSafeInteger(uncompressed) || uncompressed < 0 || uncompressed > maxRowGroupBytes || !Number.isSafeInteger(compressed) || compressed < 0 || compressed > maxRowGroupBytes) throw new RangeError("parquet stream declared group bytes exceed maxRowGroupBytes");
413
+ totalRows += rows;
414
+ }
415
+ if (!Number.isSafeInteger(totalRows) || BigInt(totalRows) !== metadata.num_rows) throw new Error("parquet stream row-count metadata mismatch");
416
+ if (values) {
417
+ yield* decodeBoundedValues(bounded, metadata, values, maxReadBytes, selection, maxReadAndDecompressBytes);
418
+ return;
419
+ }
420
+ yield* decodeGroupsFromMetadata(bounded, metadata, opts, indexedWindow && {
421
+ ...indexedWindow,
422
+ maxReadBytes
423
+ }, maxReadAndDecompressBytes);
424
+ }
425
+ function indexedWindowPolicy(policy) {
426
+ if (policy === void 0) return void 0;
427
+ const { targetRows, requiredIndexedColumns, rangeCacheBytes = 0 } = policy;
428
+ if (!Number.isSafeInteger(targetRows) || targetRows < 1 || !Array.isArray(requiredIndexedColumns) || requiredIndexedColumns.length < 1 || requiredIndexedColumns.length > 64 || requiredIndexedColumns.some((name) => typeof name !== "string" || name.length === 0) || !Number.isSafeInteger(rangeCacheBytes) || rangeCacheBytes < 0 || rangeCacheBytes > 2097152) throw new RangeError("invalid indexedRowWindow policy");
429
+ return {
430
+ targetRows,
431
+ requiredIndexedColumns: [...new Set(requiredIndexedColumns)],
432
+ rangeCacheBytes
433
+ };
434
+ }
435
+ function windowScalar(element) {
436
+ if (element.num_children || element.repetition_type === "REPEATED") return false;
437
+ const converted = element.converted_type;
438
+ const logical = element.logical_type?.type;
439
+ if (element.type === "BYTE_ARRAY") return (!converted || converted === "UTF8") && (!logical || logical === "STRING");
440
+ return [
441
+ "BOOLEAN",
442
+ "INT32",
443
+ "INT64",
444
+ "INT96",
445
+ "FLOAT",
446
+ "DOUBLE"
447
+ ].includes(element.type ?? "") && (!converted || [
448
+ "DATE",
449
+ "TIME_MILLIS",
450
+ "TIME_MICROS",
451
+ "TIMESTAMP_MILLIS",
452
+ "TIMESTAMP_MICROS",
453
+ "UINT_8",
454
+ "UINT_16",
455
+ "UINT_32",
456
+ "UINT_64",
457
+ "INT_8",
458
+ "INT_16",
459
+ "INT_32",
460
+ "INT_64"
461
+ ].includes(converted)) && (!logical || [
462
+ "DATE",
463
+ "TIME",
464
+ "TIMESTAMP",
465
+ "INTEGER"
466
+ ].includes(logical));
467
+ }
468
+ function indexedGroup(group, names, columns, fileBytes, policy) {
469
+ if (group.columns.length !== names.size || new Set(group.columns.map((col) => col.meta_data?.path_in_schema[0])).size !== names.size || group.columns.some((col) => col.meta_data?.path_in_schema.length !== 1 || !names.has(col.meta_data.path_in_schema[0]))) return false;
470
+ return policy.requiredIndexedColumns.every((name) => {
471
+ if (columns && !columns.includes(name)) return false;
472
+ const matches = group.columns.filter((col) => col.meta_data?.path_in_schema[0] === name);
473
+ if (matches.length !== 1) return false;
474
+ const chunk = matches[0];
475
+ const meta = chunk.meta_data;
476
+ if (meta.dictionary_page_offset !== void 0 && meta.dictionary_page_offset !== null || meta.encodings.some((encoding) => encoding === "PLAIN_DICTIONARY" || encoding === "RLE_DICTIONARY")) return false;
477
+ const start = Number(chunk.offset_index_offset);
478
+ const length = chunk.offset_index_length;
479
+ return Number.isSafeInteger(start) && start >= 4 && Number.isSafeInteger(length) && length > 0 && length <= policy.maxReadBytes && Number.isSafeInteger(start + length) && start + length <= fileBytes;
480
+ });
481
+ }
482
+ async function validateWindowIndexes(file, group, columns, hasFilter) {
483
+ for (const column of group.columns) {
484
+ const meta = column.meta_data;
485
+ if (columns && !hasFilter && !columns.includes(meta.path_in_schema[0])) continue;
486
+ if (!column.offset_index_offset || !column.offset_index_length) continue;
487
+ const start = Number(column.offset_index_offset);
488
+ const bytes = await file.slice(start, start + column.offset_index_length);
489
+ const pages = readOffsetIndex({
490
+ view: new DataView(bytes),
491
+ offset: 0
492
+ }).page_locations;
493
+ const groupRows = Number(group.num_rows);
494
+ const dataStart = Number(meta.data_page_offset);
495
+ const columnStart = Number(meta.dictionary_page_offset ?? meta.data_page_offset);
496
+ const columnEnd = columnStart + Number(meta.total_compressed_size);
497
+ if (!pages.length || pages.length > groupRows || !Number.isSafeInteger(dataStart) || dataStart < 4 || !Number.isSafeInteger(columnStart) || columnStart < 4 || !Number.isSafeInteger(columnEnd) || columnEnd > file.byteLength) throw new Error("parquet stream invalid offset index");
498
+ let previousRow = -1;
499
+ let previousEnd = dataStart;
500
+ for (const [index, page] of pages.entries()) {
501
+ const row = Number(page.first_row_index);
502
+ const offset = Number(page.offset);
503
+ const size = page.compressed_page_size;
504
+ if (!Number.isSafeInteger(row) || row <= previousRow || row >= groupRows || index === 0 && row !== 0 || !Number.isSafeInteger(offset) || offset < previousEnd || !Number.isSafeInteger(size) || size < 1 || !Number.isSafeInteger(offset + size) || offset + size > columnEnd) throw new Error("parquet stream invalid offset index");
505
+ previousRow = row;
506
+ previousEnd = offset + size;
507
+ }
508
+ }
509
+ }
510
+ async function* decodeGroupsFromMetadata(file, metadata, opts, indexedWindow, maxReadAndDecompressBytes) {
230
511
  const tree = parquetSchema(metadata);
231
512
  const present = /* @__PURE__ */ new Set();
232
513
  const dateColumns = [];
@@ -238,29 +519,46 @@ async function* decodeParquetGroups(bytes, opts = {}) {
238
519
  }
239
520
  const columns = opts.columns?.filter((name) => present.has(name));
240
521
  const normalizeColumns = nested ? null : dateColumns;
522
+ const windowSchema = indexedWindow && !nested && present.size === tree.children.length && tree.children.every((child) => windowScalar(child.element));
241
523
  let groupStart = 0;
242
524
  for (const group of metadata.row_groups) {
243
525
  const groupEnd = groupStart + Number(group.num_rows);
244
526
  const rowStart = Math.max(groupStart, opts.rowStart ?? 0);
245
527
  const rowEnd = Math.min(groupEnd, opts.rowEnd ?? Infinity);
246
- if (rowStart < rowEnd) yield normalizeDecodedDates(await parquetReadObjects({
247
- file,
248
- compressors: { ZSTD: (input) => decompress(input) },
249
- metadata: {
250
- ...metadata,
251
- row_groups: [group],
252
- num_rows: group.num_rows
253
- },
254
- columns,
255
- rowStart: rowStart - groupStart,
256
- rowEnd: rowEnd - groupStart,
257
- useOffsetIndex: rowStart > groupStart || rowEnd < groupEnd,
258
- ...opts.filter ? {
259
- filter: opts.filter,
260
- useBloomFilters: true,
261
- usePageIndex: true
262
- } : {}
263
- }), normalizeColumns);
528
+ if (rowStart < rowEnd) {
529
+ const window = windowSchema && indexedWindow && indexedGroup(group, present, columns, file.byteLength, indexedWindow) ? indexedWindow : void 0;
530
+ const budget = maxReadAndDecompressBytes === void 0 ? void 0 : parquetDecodeBudget(file, maxReadAndDecompressBytes);
531
+ const groupFile = budget?.file ?? file;
532
+ const cached = window?.rangeCacheBytes ? parquetRangeCache(groupFile, window.rangeCacheBytes) : void 0;
533
+ try {
534
+ if (window) await validateWindowIndexes(cached?.file ?? groupFile, group, columns, !!opts.filter);
535
+ for (let start = rowStart; start < rowEnd;) {
536
+ const end = Math.min(rowEnd, start + (window?.targetRows ?? rowEnd - rowStart));
537
+ yield normalizeDecodedDates(await parquetReadObjects({
538
+ file: cached?.file ?? groupFile,
539
+ compressors: budget?.compressors ?? { ZSTD: (input) => decompress(input) },
540
+ metadata: {
541
+ ...metadata,
542
+ row_groups: [group],
543
+ num_rows: group.num_rows
544
+ },
545
+ columns,
546
+ rowStart: start - groupStart,
547
+ rowEnd: end - groupStart,
548
+ useOffsetIndex: start > groupStart || end < groupEnd,
549
+ ...opts.filter ? {
550
+ filter: opts.filter,
551
+ useBloomFilters: true,
552
+ usePageIndex: true
553
+ } : {}
554
+ }), normalizeColumns);
555
+ start = end;
556
+ }
557
+ } finally {
558
+ budget?.close();
559
+ cached?.close();
560
+ }
561
+ }
264
562
  groupStart = groupEnd;
265
563
  }
266
564
  }
@@ -339,4 +637,4 @@ function createHyparquetCodec(options = {}) {
339
637
  }
340
638
  };
341
639
  }
342
- export { createHyparquetCodec, decodeParquetToRows, encodeRowsToParquet, encodeRowsToParquetFlex };
640
+ export { createHyparquetCodec, decodeParquetRowGroups, decodeParquetToRows, encodeParquetRowBatches, encodeRowsToParquet, encodeRowsToParquetFlex };
@@ -0,0 +1,18 @@
1
+ import "../node_modules/.pnpm/hyparquet@1.31.1/node_modules/hyparquet/types/node.mjs";
2
+ import "../storage.mjs";
3
+ export interface BoundedValuesOptions {
4
+ /** Hard emitted row ceiling, default 1000. */
5
+ targetRows?: number;
6
+ /** Conservative UTF16 + row/cell charge before materialization. Default 1 MiB. */
7
+ maxBatchBytes?: number;
8
+ /** Maximum encoded bytes of one string, checked before UTF8 conversion. Default 256 KiB. */
9
+ maxValueBytes?: number;
10
+ /** Aggregate page/dictionary backing-buffer admission, not total JS heap. Default 24 MiB. */
11
+ maxActiveBytes?: number;
12
+ /** Maximum retained page descriptors across the selected input. Default 16384. */
13
+ maxPages?: number;
14
+ /** Maximum page header probe. Default 64 KiB. */
15
+ maxHeaderBytes?: number;
16
+ /** Maximum entries per dictionary; offsets consume 4 bytes per entry. Default 100000. */
17
+ maxDictionaryEntries?: number;
18
+ }