sliftutils 1.4.47 → 1.4.49

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.
@@ -3,8 +3,43 @@
3
3
  export declare const KEY_COLUMN = "key";
4
4
  export declare const EMPTY_BUFFER: Buffer;
5
5
  export declare const ABSENT: unique symbol;
6
+ export type RawCell = {
7
+ type: number;
8
+ bytes: Buffer;
9
+ };
10
+ export declare const TYPE_ABSENT_TAG = 14;
11
+ export type ColumnIndex = {
12
+ offsets: Uint32Array;
13
+ types: Uint8Array;
14
+ readValueBytes: (startRow: number, endRow: number) => Promise<Buffer>;
15
+ };
16
+ export declare function encodeValue(value: unknown): {
17
+ type: number;
18
+ bytes: Buffer;
19
+ };
6
20
  export declare const TARGET_FILE_BYTES: number;
7
- export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): Buffer[];
21
+ export type RawRow = {
22
+ key: string;
23
+ time: number;
24
+ cells: Map<string, RawCell>;
25
+ };
26
+ export declare function columnIndexByteLength(rowCount: number): number;
27
+ export declare function assemblePlannedFile(config: {
28
+ valueColumns: {
29
+ name: string;
30
+ blob: Buffer;
31
+ }[];
32
+ keys: string[];
33
+ times: number[];
34
+ }): Buffer;
35
+ export interface BuiltFile {
36
+ buffer: Buffer;
37
+ minKey: string;
38
+ maxKey: string;
39
+ rowCount: number;
40
+ }
41
+ export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): BuiltFile[];
42
+ export declare function buildFileBufferRaw(rows: RawRow[], targetBytes?: number): BuiltFile[];
8
43
  export type BaseBulkDatabaseReader = {
9
44
  rowCount: number;
10
45
  totalBytes: number;
@@ -24,6 +59,9 @@ export type BaseBulkDatabaseReader = {
24
59
  value: unknown;
25
60
  time: number;
26
61
  }[]>;
62
+ getRawColumn: (column: string) => Promise<Map<string, RawCell>>;
63
+ getColumnIndex: (column: string) => Promise<ColumnIndex>;
64
+ rowOfKey: (key: string) => number | undefined;
27
65
  getSingleField: (key: string, column: string) => Promise<{
28
66
  value: unknown;
29
67
  time: number;
@@ -43,6 +43,35 @@ export const EMPTY_BUFFER = Buffer.alloc(0) as Buffer;
43
43
  // to an older reader for that column. Distinct from a stored undefined, which is a real clearing value.
44
44
  export const ABSENT = Symbol("absent");
45
45
 
46
+ // One value exactly as it sits on disk: its type tag + raw bytes. A cell's encoding is position-
47
+ // independent (nothing about it depends on which row/file it lives in), so a merge copies the winning
48
+ // RawCell straight from an input column into the output — never decoding it to a JS value and
49
+ // re-encoding. That's far less memory (no object materialization) and much faster (a byte copy, not a
50
+ // JPEG/typed-array decode + re-encode).
51
+ export type RawCell = { type: number; bytes: Buffer };
52
+
53
+ // The type tag a cell carries when its row never set this column — read it from the column's `types`
54
+ // array to detect ABSENT without decoding. Re-exported so the merge planner (which works at the
55
+ // column-index level, no value materialization) can check it.
56
+ export const TYPE_ABSENT_TAG = TYPE_ABSENT;
57
+
58
+ // A column's index alone (offsets + types — small, ~5 bytes/row) plus a primitive to read a CONTIGUOUS
59
+ // row range's raw value bytes. Used by the planned merge: it loads the index across every (source,
60
+ // column) once (cheap), uses types to detect ABSENT and offsets to size each cell, plans the byte
61
+ // layout of every output file from those — then in the execute phase reads only the runs of bytes it
62
+ // actually needs, copying them straight into pre-laid-out output buffers. No cell value is ever
63
+ // materialized as a JS object.
64
+ export type ColumnIndex = {
65
+ // offsets[i]..offsets[i+1] is row i's value byte range within the column's data section.
66
+ offsets: Uint32Array;
67
+ // Per-row type tag; TYPE_ABSENT_TAG marks "this row never set this column" (fall-through).
68
+ types: Uint8Array;
69
+ // Read the contiguous bytes of value(s) for rows [startRow, endRow). Length is exactly
70
+ // offsets[endRow] - offsets[startRow] — the cells in that range concatenated. The caller composes
71
+ // larger reads from consecutive rows (one source-side getRange per run).
72
+ readValueBytes: (startRow: number, endRow: number) => Promise<Buffer>;
73
+ };
74
+
46
75
  // Hidden per-row column holding each row's write-time (so reads can resolve a key to its latest value
47
76
  // by actual time). NUL-prefixed so it can't collide with a user column; excluded from `columns`.
48
77
  const TIME_COLUMN = String.fromCharCode(0) + "t";
@@ -62,7 +91,7 @@ type FileHeader = {
62
91
  maxKey?: string;
63
92
  };
64
93
 
65
- function encodeValue(value: unknown): { type: number; bytes: Buffer } {
94
+ export function encodeValue(value: unknown): { type: number; bytes: Buffer } {
66
95
  if (value === ABSENT) {
67
96
  return { type: TYPE_ABSENT, bytes: EMPTY_BUFFER };
68
97
  }
@@ -148,6 +177,26 @@ function encodeBulkData(data: unknown[]): Buffer {
148
177
  return Buffer.concat([offsets, types, ...parts]);
149
178
  }
150
179
 
180
+ // Like encodeBulkData but the values are already encoded (a merge supplies the winning cells' raw bytes
181
+ // straight from the input files). Lays out the same offsets / types / data section without touching the
182
+ // values themselves.
183
+ function encodeBulkDataRaw(cells: RawCell[]): Buffer {
184
+ const n = cells.length;
185
+ const offsets = Buffer.alloc(4 * (n + 1));
186
+ const types = Buffer.alloc(n);
187
+ const parts: Buffer[] = [];
188
+ let pos = 0;
189
+ for (let i = 0; i < n; i++) {
190
+ const { type, bytes } = cells[i];
191
+ offsets.writeUInt32LE(pos, 4 * i);
192
+ types[i] = type;
193
+ parts.push(bytes);
194
+ pos += bytes.length;
195
+ }
196
+ offsets.writeUInt32LE(pos, 4 * n);
197
+ return Buffer.concat([offsets, types, ...parts]);
198
+ }
199
+
151
200
  function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
152
201
  const indexSize = 4 * (rowCount + 1) + rowCount;
153
202
  const values: unknown[] = [];
@@ -188,21 +237,10 @@ function estimateRowBytes(row: Record<string, unknown>): number {
188
237
  return total;
189
238
  }
190
239
 
191
- function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer {
192
- const columnNames: string[] = [];
193
- const columnSet = new Set<string>();
194
- for (const row of rows) {
195
- for (const field of Object.keys(row)) {
196
- if (columnSet.has(field)) continue;
197
- columnSet.add(field);
198
- columnNames.push(field);
199
- }
200
- }
201
- // A row that doesn't include a column stores ABSENT (fall-through), not undefined (a real value).
202
- const blobs = columnNames.map(col => encodeBulkData(rows.map(row => col in row ? row[col] : ABSENT)));
203
- // The hidden per-row time column.
204
- columnNames.push(TIME_COLUMN);
205
- blobs.push(encodeBulkData(times));
240
+ // Concatenates already-encoded column blobs into a complete file (4-byte header length + header JSON +
241
+ // blobs), computing the header's time + key bounds from the per-row times and keys. Shared by the
242
+ // object-based builder (buildOneFile) and the raw-splice builder (buildOneFileRaw).
243
+ function assembleFile(columnNames: string[], blobs: Buffer[], rowCount: number, times: number[], keys: string[]): Buffer {
206
244
  let offset = 0;
207
245
  const columns = columnNames.map((name, i) => {
208
246
  const entry = { name, offset, length: blobs[i].length };
@@ -214,26 +252,111 @@ function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer
214
252
  for (const t of times) { if (t < minTime) minTime = t; if (t > maxTime) maxTime = t; }
215
253
  let minKey: string | undefined;
216
254
  let maxKey: string | undefined;
217
- for (const row of rows) {
218
- const key = row[KEY_COLUMN] as string;
255
+ for (const key of keys) {
219
256
  if (minKey === undefined || key < minKey) minKey = key;
220
257
  if (maxKey === undefined || key > maxKey) maxKey = key;
221
258
  }
222
- const header: FileHeader = { rowCount: rows.length, columns, minTime, maxTime, minKey, maxKey };
259
+ const header: FileHeader = { rowCount, columns, minTime, maxTime, minKey, maxKey };
223
260
  const headerBuf = Buffer.from(JSON.stringify(header), "utf8");
224
261
  const lengthPrefix = Buffer.alloc(4);
225
262
  lengthPrefix.writeUInt32LE(headerBuf.length, 0);
226
263
  return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
227
264
  }
228
265
 
229
- // Returns one complete, independent file buffer per chunk of rows. Rows are first sorted by key, then
266
+ function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer {
267
+ const columnNames: string[] = [];
268
+ const columnSet = new Set<string>();
269
+ for (const row of rows) {
270
+ for (const field of Object.keys(row)) {
271
+ if (columnSet.has(field)) continue;
272
+ columnSet.add(field);
273
+ columnNames.push(field);
274
+ }
275
+ }
276
+ // A row that doesn't include a column stores ABSENT (fall-through), not undefined (a real value).
277
+ const blobs = columnNames.map(col => encodeBulkData(rows.map(row => col in row ? row[col] : ABSENT)));
278
+ // The hidden per-row time column.
279
+ columnNames.push(TIME_COLUMN);
280
+ blobs.push(encodeBulkData(times));
281
+ return assembleFile(columnNames, blobs, rows.length, times, rows.map(r => r[KEY_COLUMN] as string));
282
+ }
283
+
284
+ // A resolved output row for the raw-splice merge: its key, write-time, and the winning raw cell for each
285
+ // column it has (a column it lacks is written ABSENT — fall-through). The cell bytes are copied straight
286
+ // from the input files; no value is ever decoded.
287
+ export type RawRow = { key: string; time: number; cells: Map<string, RawCell> };
288
+
289
+ // Size of a column blob's INDEX section (offsets array + types array) for a column of N rows. The data
290
+ // section follows immediately after. The planned merge uses this to size output buffers exactly.
291
+ export function columnIndexByteLength(rowCount: number): number {
292
+ return 4 * (rowCount + 1) + rowCount;
293
+ }
294
+
295
+ // Assembles a complete bulk file from a set of pre-built value column blobs plus the keys + times. Auto-
296
+ // adds the KEY_COLUMN (keys) and hidden TIME_COLUMN (times) — they're small enough to encode in one shot
297
+ // here. Used by the planned merge: it builds each value column's blob by-hand (offsets/types + raw bytes
298
+ // copied directly from inputs, no value materialization) and hands the result here for header + bounds.
299
+ export function assemblePlannedFile(config: {
300
+ valueColumns: { name: string; blob: Buffer }[];
301
+ keys: string[];
302
+ times: number[];
303
+ }): Buffer {
304
+ const columnNames = [KEY_COLUMN, ...config.valueColumns.map(c => c.name), TIME_COLUMN];
305
+ const blobs = [
306
+ encodeBulkData(config.keys),
307
+ ...config.valueColumns.map(c => c.blob),
308
+ encodeBulkData(config.times),
309
+ ];
310
+ return assembleFile(columnNames, blobs, config.keys.length, config.times, config.keys);
311
+ }
312
+
313
+ const ABSENT_CELL: RawCell = { type: TYPE_ABSENT, bytes: EMPTY_BUFFER };
314
+
315
+ function buildOneFileRaw(rows: RawRow[]): Buffer {
316
+ // Columns present in this chunk, in first-seen order (matches buildOneFile, which only emits columns
317
+ // some row actually has).
318
+ const valueColumns: string[] = [];
319
+ const seen = new Set<string>();
320
+ for (const row of rows) for (const col of row.cells.keys()) {
321
+ if (seen.has(col)) continue;
322
+ seen.add(col);
323
+ valueColumns.push(col);
324
+ }
325
+ const columnNames = [KEY_COLUMN, ...valueColumns, TIME_COLUMN];
326
+ const times = rows.map(r => r.time);
327
+ const blobs = [
328
+ encodeBulkData(rows.map(r => r.key)),
329
+ ...valueColumns.map(col => encodeBulkDataRaw(rows.map(r => r.cells.get(col) ?? ABSENT_CELL))),
330
+ encodeBulkData(times),
331
+ ];
332
+ return assembleFile(columnNames, blobs, rows.length, times, rows.map(r => r.key));
333
+ }
334
+
335
+ function estimateRawRowBytes(row: RawRow): number {
336
+ let total = row.key.length * 2 + PER_VALUE_OVERHEAD;
337
+ for (const cell of row.cells.values()) total += cell.bytes.length + PER_VALUE_OVERHEAD;
338
+ return total;
339
+ }
340
+
341
+ // One complete, independent file: the encoded buffer plus its key range + row count (the caller logs the
342
+ // range when a merge splits across several files).
343
+ export interface BuiltFile { buffer: Buffer; minKey: string; maxKey: string; rowCount: number; }
344
+
345
+ // Returns one complete, independent file per chunk of rows. Rows are first sorted by key, then
230
346
  // partitioned into key-contiguous chunks of ~targetBytes each — so every returned file is key-sorted
231
347
  // (tight minKey/maxKey for the read-skip + merge planner) and stays near the target size, and no
232
348
  // single column blob / Buffer.concat approaches the ~2GB limit. The chunks have disjoint key ranges,
233
- // so the caller just writes each as its own file. A normal-sized write returns a single buffer.
349
+ // so the caller just writes each as its own file. A normal-sized write returns a single file.
234
350
  // `times[i]` is row i's write-time, stored per row so reads resolve a key to its latest value by time.
235
- export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): Buffer[] {
236
- if (rows.length === 0) return [buildOneFile([], [])];
351
+ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): BuiltFile[] {
352
+ // A chunk is already key-sorted, so its first/last row are its min/max key.
353
+ const make = (rs: Record<string, unknown>[], ts: number[]): BuiltFile => ({
354
+ buffer: buildOneFile(rs, ts),
355
+ minKey: rs.length ? (rs[0][KEY_COLUMN] as string) : "",
356
+ maxKey: rs.length ? (rs[rs.length - 1][KEY_COLUMN] as string) : "",
357
+ rowCount: rs.length,
358
+ });
359
+ if (rows.length === 0) return [make([], [])];
237
360
  // Sort rows + their times together by key so each output file is key-contiguous.
238
361
  const order = rows.map((_, i) => i).sort((a, b) => {
239
362
  const ka = rows[a][KEY_COLUMN] as string;
@@ -242,19 +365,47 @@ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[]
242
365
  });
243
366
  const sortedRows = order.map(i => rows[i]);
244
367
  const sortedTimes = order.map(i => times[i]);
245
- const result: Buffer[] = [];
368
+ const result: BuiltFile[] = [];
246
369
  let chunkStart = 0;
247
370
  let chunkBytes = 0;
248
371
  for (let i = 0; i < sortedRows.length; i++) {
249
372
  const rowBytes = estimateRowBytes(sortedRows[i]);
250
373
  if (i > chunkStart && chunkBytes + rowBytes > targetBytes) {
251
- result.push(buildOneFile(sortedRows.slice(chunkStart, i), sortedTimes.slice(chunkStart, i)));
374
+ result.push(make(sortedRows.slice(chunkStart, i), sortedTimes.slice(chunkStart, i)));
252
375
  chunkStart = i;
253
376
  chunkBytes = 0;
254
377
  }
255
378
  chunkBytes += rowBytes;
256
379
  }
257
- result.push(buildOneFile(sortedRows.slice(chunkStart), sortedTimes.slice(chunkStart)));
380
+ result.push(make(sortedRows.slice(chunkStart), sortedTimes.slice(chunkStart)));
381
+ return result;
382
+ }
383
+
384
+ // The raw-splice counterpart of buildFileBuffer, used by merges: the rows already carry their winning
385
+ // cells as raw on-disk bytes (no JS values), so this just sorts by key, chunks to ~targetBytes, and
386
+ // concatenates the bytes. Same output guarantees: key-contiguous, disjoint, ascending files.
387
+ export function buildFileBufferRaw(rows: RawRow[], targetBytes = TARGET_FILE_BYTES): BuiltFile[] {
388
+ const make = (rs: RawRow[]): BuiltFile => ({
389
+ buffer: buildOneFileRaw(rs),
390
+ minKey: rs.length ? rs[0].key : "",
391
+ maxKey: rs.length ? rs[rs.length - 1].key : "",
392
+ rowCount: rs.length,
393
+ });
394
+ if (rows.length === 0) return [make([])];
395
+ const sorted = rows.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
396
+ const result: BuiltFile[] = [];
397
+ let chunkStart = 0;
398
+ let chunkBytes = 0;
399
+ for (let i = 0; i < sorted.length; i++) {
400
+ const rowBytes = estimateRawRowBytes(sorted[i]);
401
+ if (i > chunkStart && chunkBytes + rowBytes > targetBytes) {
402
+ result.push(make(sorted.slice(chunkStart, i)));
403
+ chunkStart = i;
404
+ chunkBytes = 0;
405
+ }
406
+ chunkBytes += rowBytes;
407
+ }
408
+ result.push(make(sorted.slice(chunkStart)));
258
409
  return result;
259
410
  }
260
411
 
@@ -279,6 +430,18 @@ export type BaseBulkDatabaseReader = {
279
430
  // Each key's value for the column plus the row's write-time. value may be ABSENT (the row never set
280
431
  // this column — the join then falls through to an older reader for that key/column).
281
432
  getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
433
+ // Like getColumn but returns each cell's raw on-disk encoding (type tag + bytes) keyed by key, WITHOUT
434
+ // decoding the value — for merges, which splice the winning bytes straight into the output. ABSENT
435
+ // cells are omitted (a missing key means this reader didn't set this column, so the merge falls
436
+ // through to an older reader). Each cell's write-time is the reader's keyTimes value for that key.
437
+ getRawColumn: (column: string) => Promise<Map<string, RawCell>>;
438
+ // Returns the column's INDEX (offsets + types) plus a contiguous row-range byte reader. The planned
439
+ // merge loads this for every (source, column) once — small (~5B/row), no value bytes pulled — and
440
+ // uses types/offsets to plan the output's byte layout. Execute then reads only the needed runs.
441
+ getColumnIndex: (column: string) => Promise<ColumnIndex>;
442
+ // Maps a key to its row index in this reader (and undefined if absent). The planned merge uses this
443
+ // to look up a winning cell's source row without going through a column read.
444
+ rowOfKey: (key: string) => number | undefined;
282
445
  // The value + write-time for (key, column), or ABSENT if this reader has no such cell.
283
446
  getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | typeof ABSENT>;
284
447
  };
@@ -352,6 +515,59 @@ export async function loadBulkDatabase(config: {
352
515
  const values = await readWholeColumn(column);
353
516
  return keys.map((key, i) => ({ key, value: values[i], time: times[i] }));
354
517
  },
518
+ async getRawColumn(column) {
519
+ const map = new Map<string, RawCell>();
520
+ const col = colByName.get(column);
521
+ if (!col) return map; // file lacks this column → every cell ABSENT (fall through)
522
+ const blob = await config.getRange(dataBase + col.offset, dataBase + col.offset + col.length);
523
+ const indexSize = 4 * (rowCount + 1) + rowCount;
524
+ for (let i = 0; i < rowCount; i++) {
525
+ const type = blob[4 * (rowCount + 1) + i];
526
+ if (type === TYPE_ABSENT) continue; // omit ABSENT so the join falls through
527
+ const start = blob.readUInt32LE(4 * i);
528
+ const end = blob.readUInt32LE(4 * (i + 1));
529
+ map.set(keys[i], { type, bytes: blob.subarray(indexSize + start, indexSize + end) });
530
+ }
531
+ return map;
532
+ },
533
+ async getColumnIndex(column) {
534
+ const col = colByName.get(column);
535
+ if (!col) {
536
+ // File lacks this column — present an all-ABSENT index so the planner can treat it
537
+ // uniformly with files that have the column.
538
+ const offsets = new Uint32Array(rowCount + 1);
539
+ const types = new Uint8Array(rowCount).fill(TYPE_ABSENT);
540
+ return {
541
+ offsets,
542
+ types,
543
+ async readValueBytes() { return EMPTY_BUFFER; },
544
+ };
545
+ }
546
+ const colBase = dataBase + col.offset;
547
+ const indexSize = 4 * (rowCount + 1) + rowCount;
548
+ // One read pulls offsets + types (small — ~5 B/row). Block cache makes subsequent value reads
549
+ // of nearby rows cheap. Decoded into aligned typed arrays so the executor can do O(1) lookups.
550
+ const indexBuf = await config.getRange(colBase, colBase + indexSize);
551
+ const offsets = new Uint32Array(rowCount + 1);
552
+ for (let i = 0; i <= rowCount; i++) offsets[i] = indexBuf.readUInt32LE(4 * i);
553
+ const types = new Uint8Array(rowCount);
554
+ for (let i = 0; i < rowCount; i++) types[i] = indexBuf[4 * (rowCount + 1) + i];
555
+ const dataStart = colBase + indexSize;
556
+ return {
557
+ offsets,
558
+ types,
559
+ async readValueBytes(startRow, endRow) {
560
+ if (endRow <= startRow) return EMPTY_BUFFER;
561
+ const start = offsets[startRow];
562
+ const end = offsets[endRow];
563
+ if (end <= start) return EMPTY_BUFFER;
564
+ return config.getRange(dataStart + start, dataStart + end);
565
+ },
566
+ };
567
+ },
568
+ rowOfKey(key) {
569
+ return keyIndex.get(key);
570
+ },
355
571
  async getSingleField(key, column) {
356
572
  const row = keyIndex.get(key);
357
573
  if (row === undefined) return ABSENT;
@@ -0,0 +1,49 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
4
+ type CopyRun = {
5
+ sourceIdx: number;
6
+ sourceStartRow: number;
7
+ sourceEndRow: number;
8
+ outputByteStart: number;
9
+ byteLength: number;
10
+ };
11
+ type PlannedOutputColumn = {
12
+ name: string;
13
+ offsets: Uint32Array;
14
+ types: Uint8Array;
15
+ dataLength: number;
16
+ runs: CopyRun[];
17
+ };
18
+ export type PlannedOutputFile = {
19
+ keys: string[];
20
+ times: number[];
21
+ minKey: string;
22
+ maxKey: string;
23
+ columns: PlannedOutputColumn[];
24
+ estimatedFileBytes: number;
25
+ sourceCounts: Map<number, number>;
26
+ };
27
+ export type PlannedMergeOutput = {
28
+ name: string;
29
+ minKey: string;
30
+ maxKey: string;
31
+ rowCount: number;
32
+ size: number;
33
+ sources: Map<string, number>;
34
+ };
35
+ export declare function runPlannedMerge(config: {
36
+ sources: BaseBulkDatabaseReader[];
37
+ sourceNames: string[];
38
+ collectionName: string;
39
+ targetFileBytes?: number;
40
+ targetBatchBytes?: number;
41
+ writeFile: (data: Buffer) => Promise<{
42
+ name: string;
43
+ size: number;
44
+ }>;
45
+ }): Promise<{
46
+ outputs: PlannedMergeOutput[];
47
+ carriedDeletes: Map<string, number>;
48
+ }>;
49
+ export {};