sliftutils 1.4.13 → 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;
@@ -1014,6 +1029,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1014
1029
  column: string;
1015
1030
  byteSize: number;
1016
1031
  }[]>;
1032
+ getKeyStats(): Promise<{
1033
+ rawKeys: number;
1034
+ finalKeys: number;
1035
+ wastedKeys: number;
1036
+ duplication: number;
1037
+ readers: number;
1038
+ }>;
1017
1039
  getReaderInfo(): Promise<{
1018
1040
  rowCount: number;
1019
1041
  totalBytes: number;
@@ -1365,6 +1387,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1365
1387
  readonly kind: "directory";
1366
1388
  readonly name: string;
1367
1389
  readonly fullPath?: string;
1390
+ readonly isRemote?: boolean;
1368
1391
  removeEntry(key: string, options?: {
1369
1392
  recursive?: boolean;
1370
1393
  }): Promise<void>;
@@ -1452,6 +1475,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1452
1475
  };
1453
1476
  export type FileStorage = IStorageRaw & {
1454
1477
  folder: NestedFileStorage;
1478
+ isRemote?: boolean;
1455
1479
  };
1456
1480
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
1457
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.13",
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;
@@ -117,6 +125,13 @@ export declare class BulkDatabaseBase<T extends {
117
125
  column: string;
118
126
  byteSize: number;
119
127
  }[]>;
128
+ getKeyStats(): Promise<{
129
+ rawKeys: number;
130
+ finalKeys: number;
131
+ wastedKeys: number;
132
+ duplication: number;
133
+ readers: number;
134
+ }>;
120
135
  getReaderInfo(): Promise<{
121
136
  rowCount: number;
122
137
  totalBytes: number;
@@ -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();
@@ -637,7 +693,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
637
693
  syncBroadcastSeal(this.name);
638
694
  this.streamFileName = undefined;
639
695
  const { bulkFiles, streamFiles } = await this.listFiles();
640
- if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles);
696
+ // compact() merges every file on disk, so nothing older survives outside it — tombstones for
697
+ // fully-deleted keys can be dropped rather than carried into a fresh carry stream.
698
+ if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
641
699
  } finally {
642
700
  releaseMergeLock(this.name, writerId);
643
701
  }
@@ -662,7 +720,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
662
720
  const selStream = streamFiles.filter(f =>
663
721
  f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
664
722
  if (selBulk.length + selStream.length < 2) return;
665
- await this.mergeFileSet(selBulk, selStream);
723
+ // timeLo <= 0 reaches the start of time: every older file overlaps [timeLo, timeHi] and is in the
724
+ // merge, so no older set survives outside it and surviving tombstones can be dropped.
725
+ await this.mergeFileSet(selBulk, selStream, timeLo <= 0);
666
726
  }
667
727
 
668
728
  // Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
@@ -804,7 +864,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
804
864
  // merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
805
865
  // only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
806
866
  // didn't change while we read it. Returns whether it produced anything.
807
- private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]): Promise<boolean> {
867
+ // `includesOldest` means this merge consumes every file at or before its time range — there is no
868
+ // file before it on disk. A surviving tombstone only exists to suppress an OLDER set in some file
869
+ // outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
870
+ // nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
871
+ // merge that reaches time 0 are the cases where this holds.)
872
+ private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false): Promise<boolean> {
808
873
  const storage = await this.storage();
809
874
  const timestamp = nextFileTime();
810
875
  const now = Date.now();
@@ -837,7 +902,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
837
902
  newNames.push(name);
838
903
  }
839
904
  }
840
- if (deletes.size) {
905
+ // Carry surviving tombstones forward only if older files exist outside this merge that they still
906
+ // need to suppress; when this merge includes the oldest data there's nothing older to suppress.
907
+ const carriedDeletes = includesOldest ? 0 : deletes.size;
908
+ if (carriedDeletes) {
841
909
  const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
842
910
  await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
843
911
  }
@@ -849,7 +917,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
849
917
  }
850
918
 
851
919
  this.resetReader();
852
- return newNames.length > 0 || deletes.size > 0;
920
+ return newNames.length > 0 || carriedDeletes > 0;
853
921
  }
854
922
 
855
923
  // A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
@@ -939,8 +1007,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
939
1007
  if (recentBytes >= FIRST_MERGE_BYTES) break;
940
1008
  }
941
1009
  const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
942
- const triggered = recent.length >= 2
943
- && (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;
944
1016
  if (triggered) {
945
1017
  const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
946
1018
  const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
@@ -1177,6 +1249,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
1177
1249
  return reader.columns;
1178
1250
  }
1179
1251
 
1252
+ // Raw vs. resolved key counts: how much duplicate/stale key data is sitting on disk that a compact()
1253
+ // would collapse. rawKeys counts every key-slot across all loaded files — each set and each delete
1254
+ // tombstone (a key written into N files counts N times); finalKeys is the number of live resolved
1255
+ // keys (after newest-write-wins and tombstones). Both come straight from the already-loaded reader, so
1256
+ // this is ~free. wastedKeys = rawKeys - finalKeys; duplication = rawKeys / finalKeys (well above 1 ⇒
1257
+ // fragmented, compaction would shrink it).
1258
+ public async getKeyStats(): Promise<{ rawKeys: number; finalKeys: number; wastedKeys: number; duplication: number; readers: number }> {
1259
+ const reader = await this.reader();
1260
+ const rawKeys = reader.rawKeyCount;
1261
+ const finalKeys = reader.keys.length;
1262
+ return {
1263
+ rawKeys,
1264
+ finalKeys,
1265
+ wastedKeys: rawKeys - finalKeys,
1266
+ duplication: finalKeys ? rawKeys / finalKeys : 0,
1267
+ readers: reader.readerCount,
1268
+ };
1269
+ }
1270
+
1180
1271
  public async getReaderInfo() {
1181
1272
  let reader = await this.reader();
1182
1273
  return {
@@ -1212,6 +1303,11 @@ type ResolvedReader = {
1212
1303
  rowCount: number;
1213
1304
  totalBytes: number;
1214
1305
  keys: string[];
1306
+ // Total key-slots across every loaded reader — every set AND every delete tombstone (a key stored in
1307
+ // N files counts N times) — and how many readers there are. Already in memory after the join, so
1308
+ // getKeyStats is ~free; rawKeyCount vs keys.length is how much duplication a compaction would collapse.
1309
+ rawKeyCount: number;
1310
+ readerCount: number;
1215
1311
  columns: { column: string; byteSize: number }[];
1216
1312
  getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
1217
1313
  getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
@@ -1256,6 +1352,8 @@ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<R
1256
1352
  totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
1257
1353
  rowCount: keys.length,
1258
1354
  keys,
1355
+ rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
1356
+ readerCount: databases.length,
1259
1357
  columns,
1260
1358
  async getColumn(column) {
1261
1359
  const perReader = await Promise.all(databases.map(async db => {
@@ -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> {