sliftutils 1.4.14 → 1.4.15

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/index.d.ts CHANGED
@@ -865,6 +865,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
865
865
  }>;
866
866
  /** Consolidate on-disk files. Optional to call; the database also does this in the background. */
867
867
  compact(): Promise<void>;
868
+ /**
869
+ * Whether this collection's storage is served over the network (a remote server) rather than local
870
+ * disk. Apps can branch on this to adapt to the higher latency. Note: over the network the database
871
+ * skips automatic background compaction by default — call the static
872
+ * `BulkDatabase2.enableNetworkCompaction()` once to opt in.
873
+ */
874
+ isRemote(): Promise<boolean>;
868
875
  /**
869
876
  * Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
870
877
  * avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
@@ -902,6 +909,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
902
909
  mergeSpacingMs: number;
903
910
  firstMergeTriggerFiles: number;
904
911
  firstMergeTriggerRangeMs: number;
912
+ streamFoldTriggerRows: number;
913
+ streamFoldTriggerBytes: number;
905
914
  writeFlushMaxDelayMs: number;
906
915
  };
907
916
  export interface ReactiveDeps {
@@ -924,13 +933,19 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
924
933
  private currentFlushDelay;
925
934
  private lastWriteTime;
926
935
  static clearCache(): void;
936
+ static enableNetworkCompaction(): void;
927
937
  storage: {
928
938
  (): Promise<FileStorage>;
929
939
  reset(): void;
930
940
  set(newValue: Promise<FileStorage>): void;
931
941
  };
942
+ isRemote(): Promise<boolean>;
943
+ private streamNeedsFold;
944
+ private automaticCompactionAllowed;
932
945
  private overlay;
933
946
  private streamTimes;
947
+ private streamRowsOnDisk;
948
+ private streamBytesOnDisk;
934
949
  private streamFileName;
935
950
  private lastMergeCheck;
936
951
  private getStreamFileName;
@@ -1372,6 +1387,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1372
1387
  readonly kind: "directory";
1373
1388
  readonly name: string;
1374
1389
  readonly fullPath?: string;
1390
+ readonly isRemote?: boolean;
1375
1391
  removeEntry(key: string, options?: {
1376
1392
  recursive?: boolean;
1377
1393
  }): Promise<void>;
@@ -1459,6 +1475,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1459
1475
  };
1460
1476
  export type FileStorage = IStorageRaw & {
1461
1477
  folder: NestedFileStorage;
1478
+ isRemote?: boolean;
1462
1479
  };
1463
1480
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
1464
1481
  export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.14",
3
+ "version": "1.4.15",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -100,6 +100,13 @@ export interface IBulkDatabase2<T extends {
100
100
  }>;
101
101
  /** Consolidate on-disk files. Optional to call; the database also does this in the background. */
102
102
  compact(): Promise<void>;
103
+ /**
104
+ * Whether this collection's storage is served over the network (a remote server) rather than local
105
+ * disk. Apps can branch on this to adapt to the higher latency. Note: over the network the database
106
+ * skips automatic background compaction by default — call the static
107
+ * `BulkDatabase2.enableNetworkCompaction()` once to opt in.
108
+ */
109
+ isRemote(): Promise<boolean>;
103
110
  /**
104
111
  * Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
105
112
  * avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
@@ -79,6 +79,14 @@ export interface IBulkDatabase2<T extends { key: string }> {
79
79
  /** Consolidate on-disk files. Optional to call; the database also does this in the background. */
80
80
  compact(): Promise<void>;
81
81
 
82
+ /**
83
+ * Whether this collection's storage is served over the network (a remote server) rather than local
84
+ * disk. Apps can branch on this to adapt to the higher latency. Note: over the network the database
85
+ * skips automatic background compaction by default — call the static
86
+ * `BulkDatabase2.enableNetworkCompaction()` once to opt in.
87
+ */
88
+ isRemote(): Promise<boolean>;
89
+
82
90
  /**
83
91
  * Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
84
92
  * avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
@@ -5,6 +5,8 @@ export declare const bulkDatabase2Timing: {
5
5
  mergeSpacingMs: number;
6
6
  firstMergeTriggerFiles: number;
7
7
  firstMergeTriggerRangeMs: number;
8
+ streamFoldTriggerRows: number;
9
+ streamFoldTriggerBytes: number;
8
10
  writeFlushMaxDelayMs: number;
9
11
  };
10
12
  export interface ReactiveDeps {
@@ -27,13 +29,19 @@ export declare class BulkDatabaseBase<T extends {
27
29
  private currentFlushDelay;
28
30
  private lastWriteTime;
29
31
  static clearCache(): void;
32
+ static enableNetworkCompaction(): void;
30
33
  storage: {
31
34
  (): Promise<FileStorage>;
32
35
  reset(): void;
33
36
  set(newValue: Promise<FileStorage>): void;
34
37
  };
38
+ isRemote(): Promise<boolean>;
39
+ private streamNeedsFold;
40
+ private automaticCompactionAllowed;
35
41
  private overlay;
36
42
  private streamTimes;
43
+ private streamRowsOnDisk;
44
+ private streamBytesOnDisk;
37
45
  private streamFileName;
38
46
  private lastMergeCheck;
39
47
  private getStreamFileName;
@@ -77,6 +77,11 @@ export const bulkDatabase2Timing = {
77
77
  firstMergeTriggerFiles: 20,
78
78
  // ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
79
79
  firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
80
+ // Tier-0 stream data can't be read per-cell — a read pulls the whole stream — so once the stream
81
+ // exceeds BOTH of these it's folded into bulk (columnar, range-readable) promptly, bypassing the merge
82
+ // throttle. The byte bound matters most over the network, where pulling the whole stream is expensive.
83
+ streamFoldTriggerRows: 100,
84
+ streamFoldTriggerBytes: 64 * 1024 * 1024,
80
85
  // Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
81
86
  // this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
82
87
  // the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
@@ -132,6 +137,11 @@ export type StorageFactory = (path: string) => Promise<FileStorage>;
132
137
  const LOAD_SIGNAL = NULL + "load";
133
138
  const OVERLAY_SIGNAL = NULL + "overlay";
134
139
 
140
+ // Over network storage we skip automatic (background) compaction by default — reading and rewriting whole
141
+ // files over the network is expensive. An app that wants it anyway opts in once via
142
+ // BulkDatabaseBase.enableNetworkCompaction(). Explicit compact()/tryMergeNow() calls are unaffected.
143
+ let networkCompactionEnabled = false;
144
+
135
145
  let fileNameCounter = 0;
136
146
  // Random per-process id baked into file names so two processes (tabs) writing the same collection
137
147
  // never collide on a name when they pick the same timestamp/counter in the same millisecond.
@@ -216,7 +226,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
216
226
  // ramping delay (see WRITE_FLUSH_FIRST_STEP_MS / writeFlushMaxDelayMs). Each buffered item keeps an
217
227
  // `apply` that re-applies its overlay mutation, so resetReader (which clears the overlay) can restore
218
228
  // writes that aren't on disk yet. Removed from the buffer only once their append succeeds.
219
- private pendingAppends: { framed: Buffer; apply: () => void }[] = [];
229
+ private pendingAppends: { framed: Buffer; apply: () => void; rows: number }[] = [];
220
230
  private flushTimer: ReturnType<typeof setTimeout> | undefined;
221
231
  private flushChain: Promise<void> = Promise.resolve();
222
232
  private currentFlushDelay = 0;
@@ -228,8 +238,34 @@ export class BulkDatabaseBase<T extends { key: string }> {
228
238
  blockCache.clear();
229
239
  }
230
240
 
241
+ // Opt in to automatic compaction even when the storage is remote (off by default — see
242
+ // networkCompactionEnabled). Global; affects every collection. Explicit compact()/tryMergeNow() always
243
+ // run regardless of this.
244
+ public static enableNetworkCompaction() {
245
+ networkCompactionEnabled = true;
246
+ }
247
+
231
248
  public storage = lazy(async () => this.storageFactory(`${BULK_ROOT_FOLDER}/${this.name}`));
232
249
 
250
+ // True when this collection's storage is served over the network (a remote server). Apps can branch on
251
+ // this to adjust behavior for the higher latency (e.g. show a "slower storage" hint, prefetch less).
252
+ public async isRemote(): Promise<boolean> {
253
+ return !!(await this.storage()).isRemote;
254
+ }
255
+
256
+ // Whether the tier-0 stream is big enough to fold into bulk now (both bounds): too many rows AND too
257
+ // many bytes to keep reading whole. See bulkDatabase2Timing.streamFoldTrigger*.
258
+ private streamNeedsFold(): boolean {
259
+ return this.streamRowsOnDisk >= bulkDatabase2Timing.streamFoldTriggerRows && this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
260
+ }
261
+
262
+ // Automatic (background) compaction is skipped over the network unless the app opted in. Explicit
263
+ // compact()/tryMergeNow() bypass this.
264
+ private async automaticCompactionAllowed(): Promise<boolean> {
265
+ if (networkCompactionEnabled) return true;
266
+ return !(await this.storage()).isRemote;
267
+ }
268
+
233
269
  // In-memory overlay of pending writes/deletes. It takes priority over the loaded readers, so writes
234
270
  // are reflected in reads without reloading. Reads observe the relevant signal; mutations invalidate it.
235
271
  //
@@ -242,6 +278,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
242
278
  // overlay to decide whether an incoming remote write is actually newer than what we have.
243
279
  private streamTimes = new Map<string, number>();
244
280
 
281
+ // Approximate size of the tier-0 stream data on disk: set accurately from the last reader build, then
282
+ // kept current as each flush appends more. Drives the stream-fold trigger (see streamNeedsFold);
283
+ // reset on resetReader, since after a merge/reset the next reader build re-measures it.
284
+ private streamRowsOnDisk = 0;
285
+ private streamBytesOnDisk = 0;
286
+
245
287
  // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
246
288
  // so concurrent writers never touch the same file.
247
289
  private streamFileName: string | undefined;
@@ -319,6 +361,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
319
361
  if (streamData.missing) filesChanged = true;
320
362
  if (filesChanged && !tolerateMissing) throw new FilesChangedError();
321
363
 
364
+ // Accurate stream size as of this load; subsequent flushes keep it current (see doFlush).
365
+ this.streamRowsOnDisk = streamData.entries.length;
366
+ this.streamBytesOnDisk = streamData.totalBytes;
367
+
322
368
  const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
323
369
  // The join resolves purely by write-time, so reader order doesn't matter.
324
370
  const readers: BaseBulkDatabaseReader[] = [];
@@ -389,6 +435,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
389
435
  this.baseFields.clear();
390
436
  this.baseFieldsLoading.clear();
391
437
  this.overlay.clear();
438
+ // The next reader build re-measures the stream; clear the estimate so a just-folded stream
439
+ // doesn't keep looking "heavy" and re-trigger a fold before then.
440
+ this.streamRowsOnDisk = 0;
441
+ this.streamBytesOnDisk = 0;
392
442
  for (const p of this.pendingAppends) p.apply();
393
443
  this.deps.invalidate(LOAD_SIGNAL);
394
444
  this.deps.invalidate(OVERLAY_SIGNAL);
@@ -422,7 +472,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
422
472
  const apply = () => { for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time); };
423
473
  this.deps.batch(apply);
424
474
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
425
- await this.streamAppend(framed, apply);
475
+ await this.streamAppend(framed, apply, stamped.length);
426
476
  void this.maybeMerge();
427
477
  }
428
478
 
@@ -437,7 +487,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
437
487
  const apply = () => { for (const { time, key } of stamped) this.setOverlayDeleted(key, time); };
438
488
  this.deps.batch(apply);
439
489
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
440
- await this.streamAppend(frameDeletes(stamped), apply);
490
+ await this.streamAppend(frameDeletes(stamped), apply, stamped.length);
441
491
  void this.maybeMerge();
442
492
  }
443
493
 
@@ -445,8 +495,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
445
495
  // `apply` re-applies this write's overlay mutation if resetReader clears the overlay before it's on
446
496
  // disk. Awaits durability only on an immediate (idle/Node) flush, so a single action — then close — is
447
497
  // saved at once; a burst returns fast and is flushed in the background.
448
- private async streamAppend(framed: Buffer, apply: () => void): Promise<void> {
449
- this.pendingAppends.push({ framed, apply });
498
+ private async streamAppend(framed: Buffer, apply: () => void, rows: number): Promise<void> {
499
+ this.pendingAppends.push({ framed, apply, rows });
450
500
  const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
451
501
  const now = Date.now();
452
502
  // Immediate when batching is off (Node / real append), or for the first write after a lull.
@@ -487,6 +537,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
487
537
  await storage.append(this.getStreamFileName(), combined);
488
538
  // New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
489
539
  this.pendingAppends.splice(0, batch.length);
540
+ // Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
541
+ this.streamBytesOnDisk += combined.length;
542
+ for (const p of batch) this.streamRowsOnDisk += p.rows;
490
543
  }
491
544
 
492
545
  public async update(entry: Partial<T> & { key: string }): Promise<void> {
@@ -603,10 +656,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
603
656
  return entries.map(e => e.entry);
604
657
  }
605
658
 
606
- // Throttled, fire-and-forget after writes: run a background merge check at most once per interval.
659
+ // Throttled, fire-and-forget after writes: run a background merge check at most once per interval — but
660
+ // a tier-0 stream that's grown too big folds promptly, bypassing the throttle. Skipped entirely over
661
+ // the network unless the app opted in (see automaticCompactionAllowed).
607
662
  private async maybeMerge(): Promise<void> {
663
+ if (!await this.automaticCompactionAllowed()) return;
608
664
  const now = Date.now();
609
- if (now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
665
+ if (!this.streamNeedsFold() && now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
610
666
  this.lastMergeCheck = now;
611
667
  try {
612
668
  await this.tryMergeNow();
@@ -951,8 +1007,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
951
1007
  if (recentBytes >= FIRST_MERGE_BYTES) break;
952
1008
  }
953
1009
  const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
954
- const triggered = recent.length >= 2
955
- && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs);
1010
+ // Fold when there's enough fragmentation to consolidate, OR when a foldable stream has grown too
1011
+ // big to keep reading whole (even if it's the only recent file) — see streamNeedsFold.
1012
+ const heavyStreamRecent = this.streamNeedsFold() && recent.some(i => i.kind === "stream");
1013
+ const triggered =
1014
+ recent.length >= 2 && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs)
1015
+ || heavyStreamRecent && recent.length >= 1;
956
1016
  if (triggered) {
957
1017
  const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
958
1018
  const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
@@ -51,6 +51,7 @@ export type DirectoryWrapper = {
51
51
  readonly kind: "directory";
52
52
  readonly name: string;
53
53
  readonly fullPath?: string;
54
+ readonly isRemote?: boolean;
54
55
  removeEntry(key: string, options?: {
55
56
  recursive?: boolean;
56
57
  }): Promise<void>;
@@ -138,6 +139,7 @@ export type NestedFileStorage = {
138
139
  };
139
140
  export type FileStorage = IStorageRaw & {
140
141
  folder: NestedFileStorage;
142
+ isRemote?: boolean;
141
143
  };
142
144
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
143
145
  export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
@@ -63,6 +63,9 @@ export type DirectoryWrapper = {
63
63
  // Full path from the storage root, for diagnostics/logging (the native handle doesn't expose paths,
64
64
  // so it's optional). e.g. "bulkDatabases2/myCollection".
65
65
  readonly fullPath?: string;
66
+ // True when this storage is served over the network (a remote server) rather than a local disk, so
67
+ // callers can adapt for the higher latency/cost (local/native handles leave it undefined = false).
68
+ readonly isRemote?: boolean;
66
69
  removeEntry(key: string, options?: { recursive?: boolean }): Promise<void>;
67
70
  getFileHandle(key: string, options?: { create?: boolean }): Promise<FileWrapper>;
68
71
  getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
@@ -509,6 +512,9 @@ export type NestedFileStorage = {
509
512
 
510
513
  export type FileStorage = IStorageRaw & {
511
514
  folder: NestedFileStorage;
515
+ // Mirrors DirectoryWrapper.isRemote: true when this storage is served over the network. Lets callers
516
+ // (e.g. BulkDatabase2, which skips automatic compaction over the network by default) adapt.
517
+ isRemote?: boolean;
512
518
  };
513
519
 
514
520
  let appendQueue = cache((key: string) => {
@@ -705,6 +711,7 @@ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
705
711
  return {
706
712
  ...wrapHandleFiles(handle),
707
713
  folder: wrapHandleNested(handle),
714
+ isRemote: handle.isRemote,
708
715
  };
709
716
  }
710
717
 
@@ -317,7 +317,16 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
317
317
  if (!accessLog.size) return;
318
318
  const rows = [...accessLog.values()].sort((a, b) => b.bytes - a.bytes);
319
319
  accessLog.clear();
320
- for (const e of rows) console.log(` [${e.op}] ${e.ip} ${e.path} ${e.count}x ${formatBytes(e.bytes)}`);
320
+ // Fixed-width columns first so lines align, then the variable-length path last. count is the
321
+ // number of requests folded into this row (R = requests, not a multiplier).
322
+ const time = new Date().toTimeString().slice(0, 8);
323
+ for (const e of rows) {
324
+ const size = formatBytes(e.bytes).padStart(9);
325
+ const reqs = (e.count + "R").padStart(6);
326
+ const op = `[${e.op.padEnd(5)}]`;
327
+ const ip = e.ip.padEnd(15);
328
+ console.log(` ${time} ${size} ${reqs} ${op} ${ip} ${e.path}`);
329
+ }
321
330
  }, 5000);
322
331
  flushTimer.unref?.();
323
332
  }
@@ -377,6 +377,7 @@ class RemoteDirectoryWrapper implements DirectoryWrapper {
377
377
  // Mirror the native FileSystemDirectoryHandle shape (name/kind/entries) so code written against the
378
378
  // native API — e.g. recursive walks using `handle.entries()` — works the same over the network.
379
379
  readonly kind = "directory" as const;
380
+ readonly isRemote = true;
380
381
  get name() { return baseName(this.dirPath); }
381
382
  get fullPath() { return this.dirPath; }
382
383
  async removeEntry(key: string): Promise<void> {