sliftutils 1.4.14 → 1.4.16

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,21 @@ 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 columnCache;
948
+ private dataGen;
949
+ private streamRowsOnDisk;
950
+ private streamBytesOnDisk;
934
951
  private streamFileName;
935
952
  private lastMergeCheck;
936
953
  private getStreamFileName;
@@ -1372,6 +1389,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1372
1389
  readonly kind: "directory";
1373
1390
  readonly name: string;
1374
1391
  readonly fullPath?: string;
1392
+ readonly isRemote?: boolean;
1375
1393
  removeEntry(key: string, options?: {
1376
1394
  recursive?: boolean;
1377
1395
  }): Promise<void>;
@@ -1459,6 +1477,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1459
1477
  };
1460
1478
  export type FileStorage = IStorageRaw & {
1461
1479
  folder: NestedFileStorage;
1480
+ isRemote?: boolean;
1462
1481
  };
1463
1482
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
1464
1483
  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.16",
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,21 @@ 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 columnCache;
44
+ private dataGen;
45
+ private streamRowsOnDisk;
46
+ private streamBytesOnDisk;
37
47
  private streamFileName;
38
48
  private lastMergeCheck;
39
49
  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,23 @@ 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
+ // Cache of fully-resolved (overlay-patched) column results, keyed by column name. The result only
282
+ // changes when the overlay mutates or the reader resets, so we keep it until then — repeat whole-column
283
+ // reads (common in a UI re-render) are then free, however large the column. Each cached array is frozen
284
+ // SHALLOWLY: Object.freeze locks the array itself (callers can't mutate a shared result) but never the
285
+ // element objects or their values, so typed-array column values keep their fast representation.
286
+ private columnCache = new Map<string, { key: string; value: unknown; time: number }[]>();
287
+ // Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
288
+ // and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
289
+ // the cache and may swap the reader) can never leave a stale entry behind.
290
+ private dataGen = 0;
291
+
292
+ // Approximate size of the tier-0 stream data on disk: set accurately from the last reader build, then
293
+ // kept current as each flush appends more. Drives the stream-fold trigger (see streamNeedsFold);
294
+ // reset on resetReader, since after a merge/reset the next reader build re-measures it.
295
+ private streamRowsOnDisk = 0;
296
+ private streamBytesOnDisk = 0;
297
+
245
298
  // This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
246
299
  // so concurrent writers never touch the same file.
247
300
  private streamFileName: string | undefined;
@@ -262,6 +315,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
262
315
  }
263
316
 
264
317
  private invalidateOverlay(key: string) {
318
+ this.dataGen++;
319
+ this.columnCache.clear();
265
320
  this.deps.invalidate(key);
266
321
  this.deps.invalidate(OVERLAY_SIGNAL);
267
322
  }
@@ -319,6 +374,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
319
374
  if (streamData.missing) filesChanged = true;
320
375
  if (filesChanged && !tolerateMissing) throw new FilesChangedError();
321
376
 
377
+ // Accurate stream size as of this load; subsequent flushes keep it current (see doFlush).
378
+ this.streamRowsOnDisk = streamData.entries.length;
379
+ this.streamBytesOnDisk = streamData.totalBytes;
380
+
322
381
  const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
323
382
  // The join resolves purely by write-time, so reader order doesn't matter.
324
383
  const readers: BaseBulkDatabaseReader[] = [];
@@ -389,6 +448,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
389
448
  this.baseFields.clear();
390
449
  this.baseFieldsLoading.clear();
391
450
  this.overlay.clear();
451
+ this.dataGen++;
452
+ this.columnCache.clear();
453
+ // The next reader build re-measures the stream; clear the estimate so a just-folded stream
454
+ // doesn't keep looking "heavy" and re-trigger a fold before then.
455
+ this.streamRowsOnDisk = 0;
456
+ this.streamBytesOnDisk = 0;
392
457
  for (const p of this.pendingAppends) p.apply();
393
458
  this.deps.invalidate(LOAD_SIGNAL);
394
459
  this.deps.invalidate(OVERLAY_SIGNAL);
@@ -422,7 +487,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
422
487
  const apply = () => { for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time); };
423
488
  this.deps.batch(apply);
424
489
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
425
- await this.streamAppend(framed, apply);
490
+ await this.streamAppend(framed, apply, stamped.length);
426
491
  void this.maybeMerge();
427
492
  }
428
493
 
@@ -437,7 +502,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
437
502
  const apply = () => { for (const { time, key } of stamped) this.setOverlayDeleted(key, time); };
438
503
  this.deps.batch(apply);
439
504
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
440
- await this.streamAppend(frameDeletes(stamped), apply);
505
+ await this.streamAppend(frameDeletes(stamped), apply, stamped.length);
441
506
  void this.maybeMerge();
442
507
  }
443
508
 
@@ -445,8 +510,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
445
510
  // `apply` re-applies this write's overlay mutation if resetReader clears the overlay before it's on
446
511
  // disk. Awaits durability only on an immediate (idle/Node) flush, so a single action — then close — is
447
512
  // 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 });
513
+ private async streamAppend(framed: Buffer, apply: () => void, rows: number): Promise<void> {
514
+ this.pendingAppends.push({ framed, apply, rows });
450
515
  const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
451
516
  const now = Date.now();
452
517
  // Immediate when batching is off (Node / real append), or for the first write after a lull.
@@ -487,6 +552,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
487
552
  await storage.append(this.getStreamFileName(), combined);
488
553
  // New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
489
554
  this.pendingAppends.splice(0, batch.length);
555
+ // Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
556
+ this.streamBytesOnDisk += combined.length;
557
+ for (const p of batch) this.streamRowsOnDisk += p.rows;
490
558
  }
491
559
 
492
560
  public async update(entry: Partial<T> & { key: string }): Promise<void> {
@@ -603,10 +671,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
603
671
  return entries.map(e => e.entry);
604
672
  }
605
673
 
606
- // Throttled, fire-and-forget after writes: run a background merge check at most once per interval.
674
+ // Throttled, fire-and-forget after writes: run a background merge check at most once per interval — but
675
+ // a tier-0 stream that's grown too big folds promptly, bypassing the throttle. Skipped entirely over
676
+ // the network unless the app opted in (see automaticCompactionAllowed).
607
677
  private async maybeMerge(): Promise<void> {
678
+ if (!await this.automaticCompactionAllowed()) return;
608
679
  const now = Date.now();
609
- if (now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
680
+ if (!this.streamNeedsFold() && now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
610
681
  this.lastMergeCheck = now;
611
682
  try {
612
683
  await this.tryMergeNow();
@@ -951,8 +1022,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
951
1022
  if (recentBytes >= FIRST_MERGE_BYTES) break;
952
1023
  }
953
1024
  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);
1025
+ // Fold when there's enough fragmentation to consolidate, OR when a foldable stream has grown too
1026
+ // big to keep reading whole (even if it's the only recent file) — see streamNeedsFold.
1027
+ const heavyStreamRecent = this.streamNeedsFold() && recent.some(i => i.kind === "stream");
1028
+ const triggered =
1029
+ recent.length >= 2 && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs)
1030
+ || heavyStreamRecent && recent.length >= 1;
956
1031
  if (triggered) {
957
1032
  const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
958
1033
  const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
@@ -1074,14 +1149,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
1074
1149
 
1075
1150
  public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column]; time: number }[]> {
1076
1151
  void this.syncSetup();
1152
+ const col = String(column);
1153
+ const cached = this.columnCache.get(col);
1154
+ if (cached) return cached as { key: string; value: T[Column]; time: number }[];
1155
+ const gen = this.dataGen;
1077
1156
  let time = Date.now();
1078
1157
  let reader = await this.reader();
1079
- let base = await reader.getColumn(String(column));
1080
- let result = this.patchColumn(base, String(column)) as { key: string; value: T[Column]; time: number }[];
1158
+ let base = await reader.getColumn(col);
1159
+ let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1081
1160
  time = Date.now() - time;
1082
1161
  if (time > 50) {
1083
1162
  console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
1084
1163
  }
1164
+ Object.freeze(result);
1165
+ // Only cache if no write/reset happened during the awaits above (else this result may be stale).
1166
+ if (this.dataGen === gen) this.columnCache.set(col, result);
1085
1167
  return result;
1086
1168
  }
1087
1169
 
@@ -1176,12 +1258,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
1176
1258
  // Observe the overlay-wide signal so we recompute once the base arrives or the overlay changes.
1177
1259
  this.deps.observe(OVERLAY_SIGNAL);
1178
1260
  let col = String(column);
1261
+ const cached = this.columnCache.get(col);
1262
+ if (cached) return cached as { key: string; value: T[Column]; time: number }[];
1179
1263
  let base = this.baseColumns.get(col);
1180
1264
  if (!base) {
1181
1265
  this.ensureBaseColumn(col);
1182
1266
  return undefined;
1183
1267
  }
1184
- return this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1268
+ // Synchronous (no awaits) reads the current overlay and caches it atomically; an observer re-runs
1269
+ // (and the cache is cleared) on any later change, so the frozen result is safe to share.
1270
+ let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1271
+ Object.freeze(result);
1272
+ this.columnCache.set(col, result);
1273
+ return result;
1185
1274
  }
1186
1275
 
1187
1276
  public async getColumnInfo() {
@@ -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> {