sliftutils 1.1.44 → 1.1.46

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.
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "WebFetch(domain:openrouter.ai)"
4
+ "WebFetch(domain:openrouter.ai)",
5
+ "Bash(git pull *)",
6
+ "Bash(echo \"---EXIT:$?---\")",
7
+ "Bash(yarn publish *)",
8
+ "Bash(git push *)"
5
9
  ]
6
10
  }
7
11
  }
package/index.d.ts CHANGED
@@ -793,8 +793,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
793
793
  private rolloverStream;
794
794
  compact(): Promise<void>;
795
795
  private listFiles;
796
+ private makeRawGetRange;
796
797
  private loadFileReader;
798
+ private fileLogicalSize;
797
799
  private handleUnreadableFile;
800
+ private mergeFilesBase;
798
801
  private mergeFiles;
799
802
  private formatInfo;
800
803
  private patchColumn;
@@ -841,7 +844,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseFormat" {
841
844
  /// <reference types="node" />
842
845
  export declare const KEY_COLUMN = "key";
843
846
  export declare const EMPTY_BUFFER: Buffer;
844
- export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer;
847
+ export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
845
848
  export type BaseBulkDatabaseReader = {
846
849
  rowCount: number;
847
850
  totalBytes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.1.44",
3
+ "version": "1.1.46",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -28,8 +28,11 @@ export declare class BulkDatabase2<T extends {
28
28
  private rolloverStream;
29
29
  compact(): Promise<void>;
30
30
  private listFiles;
31
+ private makeRawGetRange;
31
32
  private loadFileReader;
33
+ private fileLogicalSize;
32
34
  private handleUnreadableFile;
35
+ private mergeFilesBase;
33
36
  private mergeFiles;
34
37
  private formatInfo;
35
38
  private patchColumn;
@@ -14,9 +14,18 @@ import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, Re
14
14
  // folder rather than sharing bulkDatabases/.
15
15
  const BULK_ROOT_FOLDER = "bulkDatabases2";
16
16
  const FILE_EXTENSION = ".bulk";
17
- // When a level accumulates this many files they are merged into one file at the next level up, cascading, so write count stays O(log n) files.
17
+ // Once this many bulk files pile up we run a merge pass to consolidate small ones (bounded by the
18
+ // byte caps below), so reads don't fan out across an unbounded number of files.
18
19
  const MERGE_FILE_COUNT = 8;
19
20
 
21
+ // Memory ceiling for a single merge. Files are merged in contiguous (newest-first) runs whose combined
22
+ // LOGICAL (uncompressed) size stays under MERGE_MAX_BYTES, so a merge never reads more than this into
23
+ // memory at once. A file at or above MERGE_MIN_BYTES is "sealed": big enough already, never read back
24
+ // in to be merged again. Sizes are measured uncompressed (from each file's index) because that — not
25
+ // the smaller on-disk compressed size — is what actually lands in memory during a merge.
26
+ const MERGE_MIN_BYTES = 400 * 1024 * 1024;
27
+ const MERGE_MAX_BYTES = 800 * 1024 * 1024;
28
+
20
29
  // Tier-0 streaming rolls over into a columnar bulk file once it gets big enough — by row count,
21
30
  // byte size, or file count (many threads each stream to their own file). A single writeBatch that
22
31
  // already exceeds the row/byte limits skips streaming and writes a bulk file directly.
@@ -39,9 +48,21 @@ let fileNameCounter = 0;
39
48
 
40
49
  type BulkFileInfo = { fileName: string; level: number; timestamp: number };
41
50
 
42
- function newFileName(level: number): string {
51
+ let lastFileTime = 0;
52
+ // A strictly-increasing integer timestamp for newly written files, so the newest-first order is
53
+ // unambiguous even when several writes land in the same millisecond. (getTimeUnique isn't used here
54
+ // because it may return a fractional value, which wouldn't round-trip through the integer file name.)
55
+ function nextFileTime(): number {
56
+ lastFileTime = Math.max(Date.now(), lastFileTime + 1);
57
+ return lastFileTime;
58
+ }
59
+
60
+ // Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
61
+ // of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
62
+ // field kept so the name stays in the historical level_timestamp_counter shape parseFileName expects.
63
+ function newFileName(timestamp: number): string {
43
64
  fileNameCounter++;
44
- return `${level}_${Date.now()}_${fileNameCounter}${FILE_EXTENSION}`;
65
+ return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
45
66
  }
46
67
 
47
68
  type StreamFileInfo = { fileName: string; timestamp: number };
@@ -239,34 +260,18 @@ export class BulkDatabase2<T extends { key: string }> {
239
260
  await this.maybeRolloverStream();
240
261
  }
241
262
 
242
- // Writes one columnar bulk file at level 0, then cascades level merges.
263
+ // Writes the rows as one or more columnar bulk files (buildFileBuffer splits a too-large batch by
264
+ // row range so no single file approaches the Buffer size limit), all sharing one timestamp since
265
+ // they're one write with disjoint keys. Then, if enough files have accumulated, runs bounded merge
266
+ // passes until nothing more can be consolidated.
243
267
  private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
244
268
  const storage = await this.storage();
245
- await storage.set(newFileName(0), encodeCompressedBlocks(buildFileBuffer(rows)));
246
- // Cascade merge: any level at >= MERGE_FILE_COUNT files merges into one file at level+1, repeating up the tree.
247
- while (true) {
248
- const files = await this.listFiles();
249
- const byLevel = new Map<number, BulkFileInfo[]>();
250
- for (const f of files) {
251
- let list = byLevel.get(f.level);
252
- if (!list) {
253
- list = [];
254
- byLevel.set(f.level, list);
255
- }
256
- list.push(f);
257
- }
258
- const levels = [...byLevel.keys()];
259
- sort(levels, l => l);
260
- const mergeLevel = levels.find(l => {
261
- const list = byLevel.get(l);
262
- return list && list.length >= MERGE_FILE_COUNT;
263
- });
264
- if (mergeLevel === undefined) break;
265
- const levelFiles = byLevel.get(mergeLevel);
266
- if (!levelFiles) {
267
- throw new Error(`Expected files at level ${mergeLevel}, was empty`);
268
- }
269
- await this.mergeFiles(levelFiles, mergeLevel + 1);
269
+ const timestamp = nextFileTime();
270
+ for (const buffer of buildFileBuffer(rows)) {
271
+ await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
272
+ }
273
+ if ((await this.listFiles()).length >= MERGE_FILE_COUNT) {
274
+ while (await this.mergeFiles() > 0) { /* keep merging until no run can be consolidated */ }
270
275
  }
271
276
  }
272
277
 
@@ -358,13 +363,14 @@ export class BulkDatabase2<T extends { key: string }> {
358
363
  this.resetReader();
359
364
  }
360
365
 
361
- // Force-merge every on-disk file into a single new file regardless of how many there are.
366
+ // Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
367
+ // more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
368
+ // so it never loads the whole collection into memory — a multi-GB collection settles into several
369
+ // capped files (sealed files are left as-is) rather than one giant one.
362
370
  public async compact(): Promise<void> {
363
- const files = await this.listFiles();
364
- if (files.length < 2) return;
365
- const maxLevel = files.reduce((m, f) => Math.max(m, f.level), 0);
366
- await this.mergeFiles(files, maxLevel + 1);
367
- this.resetReader();
371
+ let merged = false;
372
+ while (await this.mergeFiles() > 0) merged = true;
373
+ if (merged) this.resetReader();
368
374
  }
369
375
 
370
376
  private async listFiles(): Promise<BulkFileInfo[]> {
@@ -374,20 +380,21 @@ export class BulkDatabase2<T extends { key: string }> {
374
380
  const parsed = parseFileName(n);
375
381
  return parsed && [parsed] || [];
376
382
  });
377
- // Sort is stable, so timestamps stay newest-first within each level. Lower levels are always newer than higher levels (a merge consumes every file at its level, so survivors postdate it).
378
- sort(files, f => -f.timestamp);
379
- sort(files, f => f.level);
383
+ // Newest-first by timestamp; ties broken by file name (descending) for a deterministic order.
384
+ // A merged file inherits the newest timestamp of the run it replaced, so it lands exactly where
385
+ // that run was — keeping newest-wins correct without any level bookkeeping.
386
+ files.sort((a, b) => {
387
+ if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
388
+ return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
389
+ });
380
390
  return files;
381
391
  }
382
392
 
383
- private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
393
+ private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
384
394
  const storage = await this.storage();
385
395
  const info = await storage.getInfo(fileName);
386
- if (!info) {
387
- throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
388
- }
389
- const fileId = `${this.name}\u0000${fileName}`;
390
- let getRange: GetRange = async (start, end) => {
396
+ if (!info) return undefined;
397
+ const rawGetRange: GetRange = async (start, end) => {
391
398
  if (end <= start) return EMPTY_BUFFER;
392
399
  const buf = await storage.getRange(fileName, { start, end });
393
400
  if (!buf) {
@@ -395,13 +402,37 @@ export class BulkDatabase2<T extends { key: string }> {
395
402
  }
396
403
  return buf;
397
404
  };
405
+ return { rawGetRange, size: info.size };
406
+ }
407
+
408
+ private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
409
+ const raw = await this.makeRawGetRange(fileName);
410
+ if (!raw) {
411
+ throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
412
+ }
413
+ const fileId = `${this.name}\u0000${fileName}`;
398
414
  // Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
399
415
  // decompressing version (same interface) and read the logical (uncompressed) size from its
400
416
  // index. open() validates the file size against the index and throws if it's truncated/corrupt.
401
- const opened = await blockCache.open(fileId, info.size, getRange);
417
+ const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
402
418
  return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
403
419
  }
404
420
 
421
+ // Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
422
+ // Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
423
+ // missing or unreadable so the planner simply leaves it out of any merge.
424
+ private async fileLogicalSize(fileName: string): Promise<number | undefined> {
425
+ try {
426
+ const raw = await this.makeRawGetRange(fileName);
427
+ if (!raw) return undefined;
428
+ const fileId = `${this.name}\u0000${fileName}`;
429
+ const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
430
+ return opened.uncompressedSize;
431
+ } catch {
432
+ return undefined;
433
+ }
434
+ }
435
+
405
436
  // A bulk file that won't load is either a write still in progress (recent) or a stale partial write
406
437
  // left by a crash. We can't tell which from the bytes, so we go by age: warn while it's young
407
438
  // enough that a writer could still be finishing it, and delete it once it's clearly abandoned.
@@ -422,8 +453,11 @@ export class BulkDatabase2<T extends { key: string }> {
422
453
  console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
423
454
  }
424
455
 
425
- // Read every row from `files` (already in newest-first order), pick newest-wins per key, write the merged set as a single new file at `newLevel`, then delete the consumed files.
426
- private async mergeFiles(files: BulkFileInfo[], newLevel: number): Promise<void> {
456
+ // Merges exactly the files it's given (already newest-first), newest-wins per key, writing the
457
+ // result with `timestamp` so the new file takes the slot of the run it replaced, then deletes the
458
+ // consumed files. The caller is responsible for keeping the input under MERGE_MAX_BYTES — this
459
+ // function blindly reads it all into memory.
460
+ private async mergeFilesBase(files: BulkFileInfo[], timestamp: number): Promise<void> {
427
461
  const storage = await this.storage();
428
462
  const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
429
463
 
@@ -446,12 +480,54 @@ export class BulkDatabase2<T extends { key: string }> {
446
480
  }
447
481
  }
448
482
 
449
- await storage.set(newFileName(newLevel), encodeCompressedBlocks(buildFileBuffer(mergedRows)));
483
+ // The input is under the cap, so buildFileBuffer almost always returns a single buffer; the loop
484
+ // is only here to stay correct if a merge's deduped output still happens to exceed the split size.
485
+ for (const buffer of buildFileBuffer(mergedRows)) {
486
+ await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
487
+ }
450
488
  for (const f of files) {
451
489
  await storage.remove(f.fileName);
452
490
  }
453
491
  }
454
492
 
493
+ // The cap-aware merge planner. Walks the files newest-first and merges contiguous runs of
494
+ // non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
495
+ // loads more than that into memory. A file at/over MERGE_MIN_BYTES is sealed (left untouched) and
496
+ // breaks the run, as does an unreadable file. Because each run is contiguous in the newest-first
497
+ // order and its merged file keeps the run's newest timestamp, newest-wins ordering is preserved
498
+ // with no inversions. Returns the number of runs merged (0 means nothing left to consolidate).
499
+ private async mergeFiles(): Promise<number> {
500
+ const files = await this.listFiles();
501
+ const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
502
+
503
+ const batches: BulkFileInfo[][] = [];
504
+ let batch: BulkFileInfo[] = [];
505
+ let batchBytes = 0;
506
+ const flush = () => {
507
+ // A run of one file is pointless to "merge" — only consolidate when there are at least two.
508
+ if (batch.length >= 2) batches.push(batch);
509
+ batch = [];
510
+ batchBytes = 0;
511
+ };
512
+ for (let i = 0; i < files.length; i++) {
513
+ const size = sizes[i];
514
+ if (size === undefined || size >= MERGE_MIN_BYTES) {
515
+ flush();
516
+ continue;
517
+ }
518
+ if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
519
+ batch.push(files[i]);
520
+ batchBytes += size;
521
+ }
522
+ flush();
523
+
524
+ // batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
525
+ for (const runFiles of batches) {
526
+ await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
527
+ }
528
+ return batches.length;
529
+ }
530
+
455
531
  private formatInfo(reader: BaseBulkDatabaseReader): string {
456
532
  return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
457
533
  }
@@ -2,7 +2,7 @@
2
2
  /// <reference types="node" />
3
3
  export declare const KEY_COLUMN = "key";
4
4
  export declare const EMPTY_BUFFER: Buffer;
5
- export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer;
5
+ export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
6
6
  export type BaseBulkDatabaseReader = {
7
7
  rowCount: number;
8
8
  totalBytes: number;
@@ -134,7 +134,35 @@ function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
134
134
  return values;
135
135
  }
136
136
 
137
- export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer {
137
+ // Past this estimated logical size a single rows array is split across multiple files, so no one
138
+ // file (and therefore no single column blob, and no single Buffer.concat) ever approaches the
139
+ // ~2GB Buffer length limit. The cap is deliberately the same as the merge cap: 800MB is the most
140
+ // we'll ever hold for one file. The estimate is rough on purpose — it only needs to keep each
141
+ // chunk comfortably under the limit, not be exact.
142
+ const FILE_SPLIT_BYTES = 800 * 1024 * 1024;
143
+
144
+ // Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
145
+ const PER_VALUE_OVERHEAD = 5;
146
+
147
+ function estimateValueBytes(value: unknown): number {
148
+ if (value === undefined || value === null) return 0;
149
+ if (typeof value === "string") return value.length * 2;
150
+ if (typeof value === "number") return 8;
151
+ if (typeof value === "boolean") return 1;
152
+ if (ArrayBuffer.isView(value)) return value.byteLength;
153
+ if (typeof value === "object") return JSON.stringify(value).length;
154
+ return 0;
155
+ }
156
+
157
+ function estimateRowBytes(row: Record<string, unknown>): number {
158
+ let total = 0;
159
+ for (const value of Object.values(row)) {
160
+ total += estimateValueBytes(value) + PER_VALUE_OVERHEAD;
161
+ }
162
+ return total;
163
+ }
164
+
165
+ function buildOneFile(rows: Record<string, unknown>[]): Buffer {
138
166
  const columnNames: string[] = [];
139
167
  const columnSet = new Set<string>();
140
168
  for (const row of rows) {
@@ -158,6 +186,28 @@ export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer {
158
186
  return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
159
187
  }
160
188
 
189
+ // Returns one complete, independent file buffer per chunk of rows. When the caller hands us more
190
+ // rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
191
+ // what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
192
+ // the caller just writes each as its own file. A normal-sized write returns a single buffer.
193
+ export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[] {
194
+ if (rows.length === 0) return [buildOneFile([])];
195
+ const result: Buffer[] = [];
196
+ let chunkStart = 0;
197
+ let chunkBytes = 0;
198
+ for (let i = 0; i < rows.length; i++) {
199
+ const rowBytes = estimateRowBytes(rows[i]);
200
+ if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
201
+ result.push(buildOneFile(rows.slice(chunkStart, i)));
202
+ chunkStart = i;
203
+ chunkBytes = 0;
204
+ }
205
+ chunkBytes += rowBytes;
206
+ }
207
+ result.push(buildOneFile(rows.slice(chunkStart)));
208
+ return result;
209
+ }
210
+
161
211
  export type BaseBulkDatabaseReader = {
162
212
  rowCount: number;
163
213
  totalBytes: number;
@@ -13,7 +13,11 @@ const subscribers = new Map<string, ((write: RemoteWrite) => void)[]>();
13
13
  const recentResolvers = new Map<string, ((writes: RemoteWrite[]) => void)[]>();
14
14
 
15
15
  export function isSyncSupported(): boolean {
16
- return typeof SharedWorker !== "undefined";
16
+ if (typeof SharedWorker === "undefined") return false;
17
+ // Under file:// the SharedWorker constructor throws a SecurityError (cross-origin / opaque
18
+ // origin), so skip it rather than logging that error on every load.
19
+ if (typeof location !== "undefined" && location.protocol === "file:") return false;
20
+ return true;
17
21
  }
18
22
 
19
23
  function ensure() {