sliftutils 1.4.48 → 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,7 +3,35 @@
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;
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;
7
35
  export interface BuiltFile {
8
36
  buffer: Buffer;
9
37
  minKey: string;
@@ -11,6 +39,7 @@ export interface BuiltFile {
11
39
  rowCount: number;
12
40
  }
13
41
  export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): BuiltFile[];
42
+ export declare function buildFileBufferRaw(rows: RawRow[], targetBytes?: number): BuiltFile[];
14
43
  export type BaseBulkDatabaseReader = {
15
44
  rowCount: number;
16
45
  totalBytes: number;
@@ -30,6 +59,9 @@ export type BaseBulkDatabaseReader = {
30
59
  value: unknown;
31
60
  time: number;
32
61
  }[]>;
62
+ getRawColumn: (column: string) => Promise<Map<string, RawCell>>;
63
+ getColumnIndex: (column: string) => Promise<ColumnIndex>;
64
+ rowOfKey: (key: string) => number | undefined;
33
65
  getSingleField: (key: string, column: string) => Promise<{
34
66
  value: unknown;
35
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,18 +252,92 @@ 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
 
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
+
229
341
  // One complete, independent file: the encoded buffer plus its key range + row count (the caller logs the
230
342
  // range when a merge splits across several files).
231
343
  export interface BuiltFile { buffer: Buffer; minKey: string; maxKey: string; rowCount: number; }
@@ -269,6 +381,34 @@ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[]
269
381
  return result;
270
382
  }
271
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)));
409
+ return result;
410
+ }
411
+
272
412
  export type BaseBulkDatabaseReader = {
273
413
  rowCount: number;
274
414
  totalBytes: number;
@@ -290,6 +430,18 @@ export type BaseBulkDatabaseReader = {
290
430
  // Each key's value for the column plus the row's write-time. value may be ABSENT (the row never set
291
431
  // this column — the join then falls through to an older reader for that key/column).
292
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;
293
445
  // The value + write-time for (key, column), or ABSENT if this reader has no such cell.
294
446
  getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | typeof ABSENT>;
295
447
  };
@@ -363,6 +515,59 @@ export async function loadBulkDatabase(config: {
363
515
  const values = await readWholeColumn(column);
364
516
  return keys.map((key, i) => ({ key, value: values[i], time: times[i] }));
365
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
+ },
366
571
  async getSingleField(key, column) {
367
572
  const row = keyIndex.get(key);
368
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 {};