sliftutils 1.4.2 → 1.4.4

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.
@@ -8,6 +8,7 @@ import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
8
8
  import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
9
9
  import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
10
10
  import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
11
+ import { isNode } from "typesafecss";
11
12
  import type { FileStorage } from "../FileFolderAPI";
12
13
 
13
14
  // ───────────────────────────────────────────────────────────────────────────────────────────────
@@ -52,6 +53,13 @@ const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
52
53
  const KEY_GROUP_BYTES = 800 * 1024 * 1024;
53
54
  const DUP_THRESHOLD = 0.4;
54
55
 
56
+ // The browser File System Access API has no real append — every "append" rewrites the whole file — so
57
+ // streaming one write at a time is quadratic. We coalesce stream writes and flush on a RAMPING delay:
58
+ // the first write after a lull flushes immediately (a single checkbox-then-close is saved at once), and
59
+ // as writes keep coming the delay doubles from this step up to writeFlushMaxDelayMs, batching a burst
60
+ // into one rewrite. (Node has a real append, so there the default delay is 0 = flush every write.)
61
+ const WRITE_FLUSH_FIRST_STEP_MS = 250;
62
+
55
63
  // Time thresholds, mutable so tests can shrink them from hours to milliseconds.
56
64
  export const bulkDatabase2Timing = {
57
65
  // A writer stops appending to its current stream file once it's this old (starts a fresh one). A
@@ -60,10 +68,20 @@ export const bulkDatabase2Timing = {
60
68
  streamSealAgeMs: 10 * 60 * 60 * 1000,
61
69
  // Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
62
70
  mergeCheckIntervalMs: 30 * 60 * 1000,
71
+ // A single testMerge can do several merges (one pass-1 + several pass-2 groups). We wait this long
72
+ // after each one before the next, so a burst of writes doesn't rewrite every index on disk at once —
73
+ // it spreads the work out to keep peak lag low (important in the browser). The merge lock is kept
74
+ // alive across the wait. Set to 0 to disable spacing (tests).
75
+ mergeSpacingMs: 5 * 60 * 1000,
63
76
  // The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
64
77
  firstMergeTriggerFiles: 20,
65
78
  // ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
66
79
  firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
80
+ // Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
81
+ // this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
82
+ // the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
83
+ // the whole stream file per write.
84
+ writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
67
85
  };
68
86
 
69
87
  // Marks a key as deleted in the in-memory overlay.
@@ -177,7 +195,32 @@ export class BulkDatabaseBase<T extends { key: string }> {
177
195
  public readonly name: string,
178
196
  protected deps: ReactiveDeps,
179
197
  private storageFactory: StorageFactory,
180
- ) { }
198
+ ) {
199
+ // Best-effort: persist buffered stream writes when the tab is hidden/closing. visibilitychange →
200
+ // hidden fires early enough that the (async) flush usually completes; pagehide is the backstop.
201
+ // No-op outside a browser window (Node).
202
+ if (typeof window !== "undefined") {
203
+ try {
204
+ window.addEventListener("pagehide", () => void this.flushPending());
205
+ if (typeof document !== "undefined") {
206
+ document.addEventListener("visibilitychange", () => {
207
+ if (document.visibilityState === "hidden") void this.flushPending();
208
+ });
209
+ }
210
+ } catch { /* not in a DOM context */ }
211
+ }
212
+ }
213
+
214
+ // ---- buffered stream writes (per collection) ----
215
+ // The browser rewrites the whole stream file on every append, so we coalesce writes and flush on a
216
+ // ramping delay (see WRITE_FLUSH_FIRST_STEP_MS / writeFlushMaxDelayMs). Each buffered item keeps an
217
+ // `apply` that re-applies its overlay mutation, so resetReader (which clears the overlay) can restore
218
+ // writes that aren't on disk yet. Removed from the buffer only once their append succeeds.
219
+ private pendingAppends: { framed: Buffer; apply: () => void }[] = [];
220
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
221
+ private flushChain: Promise<void> = Promise.resolve();
222
+ private currentFlushDelay = 0;
223
+ private lastWriteTime = 0;
181
224
 
182
225
  // Block range cache is global and immutable-file-safe; clear it to simulate a cold page load
183
226
  // (e.g. between an untimed prep step and the timed benchmark).
@@ -331,7 +374,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
331
374
  }
332
375
 
333
376
  // Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
334
- // changes (large direct-bulk write, rollover, compact) after the data has been persisted.
377
+ // changes (large direct-bulk write, rollover, compact) after the data has been persisted. Writes
378
+ // still buffered (not yet on disk) are re-applied so the reset doesn't drop them from reads — they
379
+ // aren't in the reloaded reader until their append lands.
335
380
  private resetReader() {
336
381
  this.deps.batch(() => {
337
382
  this.reader.reset();
@@ -340,6 +385,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
340
385
  this.baseFields.clear();
341
386
  this.baseFieldsLoading.clear();
342
387
  this.overlay.clear();
388
+ for (const p of this.pendingAppends) p.apply();
343
389
  this.deps.invalidate(LOAD_SIGNAL);
344
390
  this.deps.invalidate(OVERLAY_SIGNAL);
345
391
  });
@@ -367,14 +413,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
367
413
  return;
368
414
  }
369
415
 
370
- // Otherwise append to this thread's stream file (one cheap append) and reflect it in the
371
- // overlay immediately no reader reset.
372
- const storage = await this.storage();
373
- await storage.append(this.getStreamFileName(), framed);
374
- this.deps.batch(() => {
375
- for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
376
- });
416
+ // Reflect in the overlay + broadcast to other tabs IMMEDIATELY (in-memory + cross-tab are always
417
+ // current), but buffer the disk append and flush it on a ramping schedule.
418
+ const apply = () => { for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time); };
419
+ this.deps.batch(apply);
377
420
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
421
+ await this.streamAppend(framed, apply);
378
422
  void this.maybeMerge();
379
423
  }
380
424
 
@@ -386,15 +430,61 @@ export class BulkDatabaseBase<T extends { key: string }> {
386
430
  if (!keys.length) return;
387
431
  void this.syncSetup();
388
432
  const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
389
- const storage = await this.storage();
390
- await storage.append(this.getStreamFileName(), frameDeletes(stamped));
391
- this.deps.batch(() => {
392
- for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
393
- });
433
+ const apply = () => { for (const { time, key } of stamped) this.setOverlayDeleted(key, time); };
434
+ this.deps.batch(apply);
394
435
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
436
+ await this.streamAppend(frameDeletes(stamped), apply);
395
437
  void this.maybeMerge();
396
438
  }
397
439
 
440
+ // Buffers framed stream bytes and flushes on a ramping per-collection schedule (see the field block).
441
+ // `apply` re-applies this write's overlay mutation if resetReader clears the overlay before it's on
442
+ // disk. Awaits durability only on an immediate (idle/Node) flush, so a single action — then close — is
443
+ // saved at once; a burst returns fast and is flushed in the background.
444
+ private async streamAppend(framed: Buffer, apply: () => void): Promise<void> {
445
+ this.pendingAppends.push({ framed, apply });
446
+ const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
447
+ const now = Date.now();
448
+ // Immediate when batching is off (Node / real append), or for the first write after a lull.
449
+ if (max <= 0 || this.currentFlushDelay <= 0 || now - this.lastWriteTime > max) {
450
+ this.lastWriteTime = now;
451
+ this.currentFlushDelay = max > 0 ? Math.min(max, WRITE_FLUSH_FIRST_STEP_MS) : 0;
452
+ await this.flushPending();
453
+ return;
454
+ }
455
+ // Active burst: coalesce into one scheduled flush and ramp the delay toward max.
456
+ this.lastWriteTime = now;
457
+ if (this.flushTimer === undefined) {
458
+ this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flushPending(); }, this.currentFlushDelay);
459
+ }
460
+ this.currentFlushDelay = Math.min(max, this.currentFlushDelay * 2);
461
+ }
462
+
463
+ // Flushes all buffered stream writes to disk as one append. Serialized (so two flushes never write the
464
+ // same file concurrently) and best-effort: a failed append keeps the data buffered for the next try.
465
+ public async flush(): Promise<void> {
466
+ await this.flushPending();
467
+ }
468
+
469
+ private async flushPending(): Promise<void> {
470
+ if (this.flushTimer !== undefined) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
471
+ this.flushChain = this.flushChain.then(() => this.doFlush()).catch(e => {
472
+ console.warn(`${this.name}: stream flush failed, will retry: ${(e as Error).message}`);
473
+ });
474
+ return this.flushChain;
475
+ }
476
+
477
+ private async doFlush(): Promise<void> {
478
+ if (!this.pendingAppends.length) return;
479
+ const batch = this.pendingAppends.slice();
480
+ const combined = Buffer.concat(batch.map(p => p.framed));
481
+ const storage = await this.storage();
482
+ // Throws on failure -> we don't splice, so the data stays buffered and a later flush retries it.
483
+ await storage.append(this.getStreamFileName(), combined);
484
+ // New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
485
+ this.pendingAppends.splice(0, batch.length);
486
+ }
487
+
398
488
  public async update(entry: Partial<T> & { key: string }): Promise<void> {
399
489
  return this.updateBatch([entry]);
400
490
  }
@@ -539,6 +629,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
539
629
  public async compact(): Promise<void> {
540
630
  if (!tryAcquireMergeLock(this.name, writerId)) return; // someone else is already merging; fine
541
631
  try {
632
+ await this.flushPending(); // get buffered writes on disk so they're folded in
542
633
  syncBroadcastSeal(this.name);
543
634
  this.streamFileName = undefined;
544
635
  const { bulkFiles, streamFiles } = await this.listFiles();
@@ -773,17 +864,44 @@ export class BulkDatabaseBase<T extends { key: string }> {
773
864
  return !!info && info.size === readSize;
774
865
  }
775
866
 
776
- // The merge policy. Up to two passes:
777
- // 1) Consolidate recent fragmentation: take the newest files up to ~FIRST_MERGE_BYTES and, if they
778
- // number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs, merge them
779
- // into one file. Seals first so recent stream data is complete; in Node (no cross-tab seal) only
780
- // aged streams are folded, so we never re-fold the same un-deletable stream forever.
781
- // 2) Key-stratify: sort all keys, walk them in ~KEY_GROUP_BYTES groups, and rewrite the single group
782
- // whose fraction of duplicate (multi-file) keys is highest above DUP_THRESHOLD merging every
783
- // bulk file overlapping that key range. Over time this sorts the data into key-disjoint files.
784
- // Returns whether either pass merged anything.
867
+ // Waits mergeSpacingMs between merges so a burst of work doesn't rewrite every index at once (keeps
868
+ // peak lag low for the browser). Heartbeats the merge lock through the wait; returns false if another
869
+ // tab took the lock over, so the caller stops doing further merges.
870
+ private async mergeSpacingDelay(): Promise<boolean> {
871
+ const total = bulkDatabase2Timing.mergeSpacingMs;
872
+ if (total <= 0) return tryAcquireMergeLock(this.name, writerId);
873
+ const step = 15 * 1000; // re-stamp the lock well within its TTL
874
+ let waited = 0;
875
+ while (waited < total) {
876
+ await new Promise<void>(r => setTimeout(r, Math.min(step, total - waited)));
877
+ waited += step;
878
+ if (!tryAcquireMergeLock(this.name, writerId)) return false; // another tab took over
879
+ }
880
+ return true;
881
+ }
882
+
883
+ // The merge policy. Two passes, with a mergeSpacingMs pause after every merge (so a write burst
884
+ // doesn't rewrite all indexes at once):
885
+ // 1) Consolidate recent fragmentation (one merge): take the newest files up to ~FIRST_MERGE_BYTES
886
+ // and, if they number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs,
887
+ // merge them into one file. Seals first so recent stream data is complete; in Node (no cross-tab
888
+ // seal) only aged streams are folded, so we never re-fold the same un-deletable stream forever.
889
+ // 2) Key-stratify (possibly several merges): sort all keys, walk them in ~KEY_GROUP_BYTES groups, and
890
+ // rewrite EVERY group whose fraction of duplicate (multi-file) keys exceeds DUP_THRESHOLD —
891
+ // highest-duplication first — merging the bulk files overlapping that key range. Groups have
892
+ // disjoint key ranges, so one group's merge doesn't change another's duplication; we re-select
893
+ // each group's files at merge time (the file set shifts as we go). Over time this sorts the data
894
+ // into key-disjoint files. Returns whether any merge happened.
785
895
  private async testMerge(): Promise<boolean> {
786
896
  let merged = false;
897
+ await this.flushPending(); // get buffered writes on disk so this pass can fold/consider them
898
+ // Run a merge, pausing first if we've already merged this pass (so the pause is BETWEEN merges,
899
+ // never before the first or after the last). Returns false if we lost the lock — stop entirely.
900
+ const runMerge = async (bulk: BulkFileInfo[], stream: StreamFileInfo[]): Promise<boolean> => {
901
+ if (merged && !await this.mergeSpacingDelay()) return false;
902
+ if (await this.mergeFileSet(bulk, stream)) merged = true;
903
+ return true;
904
+ };
787
905
 
788
906
  // ── Pass 1: consolidate recent files. ──
789
907
  // Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
@@ -822,62 +940,69 @@ export class BulkDatabaseBase<T extends { key: string }> {
822
940
  if (triggered) {
823
941
  const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
824
942
  const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
825
- if (await this.mergeFileSet(rb, rs)) merged = true;
943
+ if (!await runMerge(rb, rs)) return merged;
826
944
  }
827
945
  }
828
946
 
829
947
  // ── Pass 2: key-stratify the bulk files to remove duplication. ──
830
- {
948
+ // Compute the qualifying groups' key ranges once (over a post-pass-1 listing); their key ranges
949
+ // are disjoint, so they stay valid as we merge each in turn.
950
+ const groups = await this.findDuplicateGroups();
951
+ for (const g of groups) {
952
+ // Re-select the files overlapping this group's key range now (earlier merges shifted the set).
831
953
  const { bulkFiles } = await this.listFiles();
832
- if (bulkFiles.length >= 2) {
833
- const infos = await Promise.all(bulkFiles.map(async f => {
834
- try {
835
- const reader = await this.loadFileReader(f.fileName);
836
- const keys = reader.keys;
837
- let min = keys[0], max = keys[0];
838
- for (const k of keys) { if (k < min) min = k; if (k > max) max = k; }
839
- return { file: f, keys, bytes: reader.totalBytes, min, max };
840
- } catch {
841
- return { file: f, keys: [] as string[], bytes: 0, min: undefined as string | undefined, max: undefined as string | undefined };
842
- }
843
- }));
844
- const usable = infos.filter(i => i.keys.length > 0);
845
- const keyCount = new Map<string, number>();
846
- let totalSlots = 0, totalBytes = 0;
847
- for (const i of usable) {
848
- totalBytes += i.bytes;
849
- for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
850
- }
851
- if (totalSlots > 0) {
852
- const bytesPerSlot = totalBytes / totalSlots;
853
- const sortedKeys = [...keyCount.keys()].sort();
854
- // Walk sorted keys forming ~KEY_GROUP_BYTES groups; remember the group with the highest
855
- // duplicate fraction over the threshold (the most benefit), then merge its files.
856
- let best: { lo: string; hi: string; dup: number } | undefined;
857
- let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
858
- for (let i = 0; i < sortedKeys.length; i++) {
859
- const c = keyCount.get(sortedKeys[i])!;
860
- gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
861
- if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
862
- const dup = (gSlots - gUnique) / gSlots;
863
- if (dup > DUP_THRESHOLD && (!best || dup > best.dup)) best = { lo: sortedKeys[gStart], hi: sortedKeys[i], dup };
864
- gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
865
- }
866
- }
867
- if (best) {
868
- const lo = best.lo, hi = best.hi;
869
- const groupFiles = usable
870
- .filter(i => i.min !== undefined && i.max !== undefined && i.min <= hi && i.max >= lo)
871
- .map(i => i.file);
872
- if (groupFiles.length >= 2 && await this.mergeFileSet(groupFiles, [])) merged = true;
873
- }
874
- }
875
- }
954
+ const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
955
+ const groupFiles = bulkFiles.filter((f, i) => {
956
+ const h = headers[i];
957
+ if (!h) return false;
958
+ // Old files without a key range are treated as spanning all keys (so always overlap).
959
+ if (h.minKey === undefined || h.maxKey === undefined) return true;
960
+ return h.minKey <= g.hi && h.maxKey >= g.lo;
961
+ });
962
+ if (groupFiles.length >= 2) { if (!await runMerge(groupFiles, [])) return merged; }
876
963
  }
877
964
 
878
965
  return merged;
879
966
  }
880
967
 
968
+ // Finds key-range groups worth deduping: sorts all bulk keys, walks them in ~KEY_GROUP_BYTES groups,
969
+ // and returns the [lo, hi] of every group whose duplicate (multi-file) key fraction exceeds
970
+ // DUP_THRESHOLD, highest-duplication first (most benefit). Empty when nothing is worth it.
971
+ private async findDuplicateGroups(): Promise<{ lo: string; hi: string; dup: number }[]> {
972
+ const { bulkFiles } = await this.listFiles();
973
+ if (bulkFiles.length < 2) return [];
974
+ const infos = await Promise.all(bulkFiles.map(async f => {
975
+ try {
976
+ const reader = await this.loadFileReader(f.fileName);
977
+ return { keys: reader.keys, bytes: reader.totalBytes };
978
+ } catch {
979
+ return { keys: [] as string[], bytes: 0 };
980
+ }
981
+ }));
982
+ const keyCount = new Map<string, number>();
983
+ let totalSlots = 0, totalBytes = 0;
984
+ for (const i of infos) {
985
+ totalBytes += i.bytes;
986
+ for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
987
+ }
988
+ if (totalSlots === 0) return [];
989
+ const bytesPerSlot = totalBytes / totalSlots;
990
+ const sortedKeys = [...keyCount.keys()].sort();
991
+ const groups: { lo: string; hi: string; dup: number }[] = [];
992
+ let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
993
+ for (let i = 0; i < sortedKeys.length; i++) {
994
+ const c = keyCount.get(sortedKeys[i])!;
995
+ gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
996
+ if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
997
+ const dup = (gSlots - gUnique) / gSlots;
998
+ if (dup > DUP_THRESHOLD) groups.push({ lo: sortedKeys[gStart], hi: sortedKeys[i], dup });
999
+ gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
1000
+ }
1001
+ }
1002
+ groups.sort((a, b) => b.dup - a.dup);
1003
+ return groups;
1004
+ }
1005
+
881
1006
  private formatInfo(reader: ResolvedReader): string {
882
1007
  return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
883
1008
  }
@@ -1058,6 +1183,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
1058
1183
  columns: reader.columns,
1059
1184
  };
1060
1185
  }
1186
+
1187
+ // Per-file breakdown of the collection's on-disk files, read FRESH from disk each call (so it
1188
+ // reflects the latest sizes, including stream files still being appended). `bytes` is the actual
1189
+ // on-disk (compressed, for bulk) size. Useful for showing collection size / fragmentation, and to
1190
+ // decide whether to call tryMergeNow()/compact().
1191
+ public async getFileInfo(): Promise<{ files: { name: string; type: "bulk" | "stream"; bytes: number }[]; count: number; totalBytes: number }> {
1192
+ const { bulkFiles, streamFiles } = await this.listFiles();
1193
+ const storage = await this.storage();
1194
+ const sizeOf = async (name: string) => { try { return (await storage.getInfo(name))?.size ?? 0; } catch { return 0; } };
1195
+ const files = [
1196
+ ...await Promise.all(bulkFiles.map(async f => ({ name: f.fileName, type: "bulk" as const, bytes: await sizeOf(f.fileName) }))),
1197
+ ...await Promise.all(streamFiles.map(async f => ({ name: f.fileName, type: "stream" as const, bytes: await sizeOf(f.fileName) }))),
1198
+ ];
1199
+ return { files, count: files.length, totalBytes: files.reduce((a, f) => a + f.bytes, 0) };
1200
+ }
1061
1201
  }
1062
1202
 
1063
1203
  // The merged, time-resolved view over all readers. getColumn/getSingleField return the resolved value
@@ -21,15 +21,17 @@ function lockKey(collection: string): string {
21
21
  return "bulkDatabase2-merge:" + collection;
22
22
  }
23
23
 
24
- // Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which
25
- // case we proceed and rely on the manifest backstop). Returns false if another tab holds a fresh lock.
24
+ // Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which case
25
+ // we proceed concurrent merges are harmless). Returns false only if a DIFFERENT holder has a fresh
26
+ // lock. Re-stamping our own lock always succeeds, so this doubles as a heartbeat: call it periodically
27
+ // during a long, spaced-out merge to keep the lock alive (and detect if another tab took it over).
26
28
  export function tryAcquireMergeLock(collection: string, holderId: string): boolean {
27
29
  const ls = getLocalStorage();
28
30
  if (!ls) return true;
29
31
  const key = lockKey(collection);
30
32
  const now = Date.now();
31
33
  const existing = ls.getItem(key);
32
- if (existing) {
34
+ if (existing && !existing.startsWith(holderId + ":")) {
33
35
  const t = parseInt(existing.slice(existing.lastIndexOf(":") + 1), 10);
34
36
  if (Number.isFinite(t) && now - t < LOCK_TTL_MS) return false;
35
37
  }
@@ -24,13 +24,10 @@ function frame(payload: Buffer): Buffer {
24
24
  return Buffer.concat([len, payload, len]);
25
25
  }
26
26
 
27
- // We deliberately do NOT batch/debounce writes here each write is framed and appended (and flushed)
28
- // immediately. If a caller makes many individual writes and that causes lag, the fix is for them to
29
- // call writeBatch/deleteBatch with the whole set, not for us to silently coalesce. Caller-side
30
- // batching is strictly faster than anything we could do (it knows the full set up front), and not
31
- // batching gives the lowest possible latency for callers who genuinely want single writes — which
32
- // matters because the tab can be closed at any moment, so we want each write on disk as soon as
33
- // possible rather than sitting in a pending buffer that a close would lose.
27
+ // Framing only no batching here. BulkDatabase2 coalesces and flushes these framed bytes on a ramping
28
+ // per-collection schedule (see streamAppend in BulkDatabaseBase), because the browser File System Access
29
+ // API rewrites the whole file on every append, so one-append-per-write is quadratic. The first write
30
+ // after a lull still flushes immediately, so a single action then a tab close is saved at once.
34
31
 
35
32
  // Times are assigned by the caller (BulkDatabase2) so the exact same timestamp lands on disk, in the
36
33
  // in-memory overlay, and in the cross-tab broadcast — keeping the global write order consistent.
@@ -7,6 +7,7 @@ import { css, isNode } from "typesafecss";
7
7
  import { IStorageRaw } from "./IStorage";
8
8
  import { runInSerial } from "socket-function/src/batching";
9
9
  import { getFileStorageIndexDB } from "./IndexedDBFileFolderAPI";
10
+ import { getRemoteFileStorage } from "./remoteFileStorage";
10
11
  import fs from "fs";
11
12
  import path from "path";
12
13
 
@@ -78,6 +79,32 @@ export function setFileAPIKey(key: string) {
78
79
  fileAPIKey = key;
79
80
  }
80
81
 
82
+ // ---- remote (server) storage config ----
83
+ // Instead of a local folder, the user can point at a remoteFileServer.js instance (URL + password).
84
+ // When configured, getFileStorageNested2 serves everything from that server. Persisted in localStorage.
85
+ type RemoteConfig = { url: string; password: string };
86
+ function remoteConfigKey() { return getFileAPIKey() + ":remote"; }
87
+ function loadRemoteConfig(): RemoteConfig | undefined {
88
+ try {
89
+ const s = localStorage.getItem(remoteConfigKey());
90
+ if (!s) return undefined;
91
+ const c = JSON.parse(s);
92
+ if (c && typeof c.url === "string" && typeof c.password === "string") return c;
93
+ } catch { /* ignore */ }
94
+ return undefined;
95
+ }
96
+ function saveRemoteConfig(c: RemoteConfig) { localStorage.setItem(remoteConfigKey(), JSON.stringify(c)); }
97
+
98
+ // One shared factory (and therefore one shared range cache) per remote config.
99
+ let remoteFactory: { key: string; factory: ReturnType<typeof getRemoteFileStorage> } | undefined;
100
+ function getRemoteFactory(remote: RemoteConfig) {
101
+ const key = remote.url + "\0" + remote.password;
102
+ if (!remoteFactory || remoteFactory.key !== key) {
103
+ remoteFactory = { key, factory: getRemoteFileStorage(remote.url, remote.password) };
104
+ }
105
+ return remoteFactory.factory;
106
+ }
107
+
81
108
  @observer
82
109
  class DirectoryPrompter extends preact.Component {
83
110
  render() {
@@ -96,6 +123,53 @@ class DirectoryPrompter extends preact.Component {
96
123
  }
97
124
  }
98
125
 
126
+ // "Connect to a server" option for the directory prompt: collapses to a button, expands to URL +
127
+ // password fields. On connect it validates the credentials against the server, persists the config, and
128
+ // reloads so getFileStorageNested2 picks up the remote storage.
129
+ @observer
130
+ class ServerConnectForm extends preact.Component {
131
+ private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false });
132
+ private connect = async () => {
133
+ const s = this.obs;
134
+ s.error = "";
135
+ s.connecting = true;
136
+ try {
137
+ const url = s.url.trim().replace(/\/+$/, "");
138
+ if (!url) throw new Error("Enter a server URL");
139
+ // A successful listing proves the URL is reachable and the password is correct.
140
+ await (await getRemoteFileStorage(url, s.password.trim())("")).getKeys();
141
+ saveRemoteConfig({ url, password: s.password.trim() });
142
+ window.location.reload();
143
+ } catch (e) {
144
+ s.error = "Could not connect: " + ((e as Error).message || String(e));
145
+ s.connecting = false;
146
+ }
147
+ };
148
+ render() {
149
+ const s = this.obs;
150
+ const inputCss = css.fontSize(28).pad2(24, 14).width(560).maxWidth("80vw");
151
+ const btnCss = css.fontSize(32).pad2(60, 30);
152
+ if (!s.expanded) {
153
+ return <button className={btnCss} onClick={() => s.expanded = true}>Connect to a server</button>;
154
+ }
155
+ return (
156
+ <div className={css.vbox(16).center}>
157
+ <input className={inputCss} placeholder="https://host:8787" value={s.url}
158
+ onInput={e => s.url = (e.target as HTMLInputElement).value} />
159
+ <input className={inputCss} type="password" placeholder="password (six words)" value={s.password}
160
+ onInput={e => s.password = (e.target as HTMLInputElement).value} />
161
+ {s.error ? <div className={css.fontSize(20).color("red").maxWidth("80vw")}>{s.error}</div> : null}
162
+ <div className={css.hbox(16)}>
163
+ <button className={btnCss} disabled={s.connecting} onClick={this.connect}>
164
+ {s.connecting ? "Connecting…" : "Connect"}
165
+ </button>
166
+ <button className={btnCss} onClick={() => { s.expanded = false; s.error = ""; }}>Back</button>
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+ }
172
+
99
173
  export class NodeJSFileHandleWrapper implements FileWrapper {
100
174
  constructor(private filePath: string) {
101
175
  }
@@ -317,6 +391,7 @@ export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Prom
317
391
  >
318
392
  Pick Data Directory
319
393
  </button>
394
+ <ServerConnectForm />
320
395
  <button
321
396
  className={css.fontSize(40).pad2(80, 40)}
322
397
  onClick={() => {
@@ -359,6 +434,7 @@ export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Prom
359
434
  >
360
435
  Pick Data Directory
361
436
  </button>
437
+ <ServerConnectForm />
362
438
  <button
363
439
  className={css.fontSize(40).pad2(80, 40)}
364
440
  onClick={() => {
@@ -388,6 +464,10 @@ export const getFileStorageNested = cache(async function getFileStorage(path: st
388
464
  export const getFileStorageNested2 = cache(async function getFileStorage(pathStr: string): Promise<FileStorage> {
389
465
  let base: DirectoryWrapper;
390
466
  pathStr = pathStr.replaceAll("\\", "/");
467
+ if (!isNode()) {
468
+ const remote = loadRemoteConfig();
469
+ if (remote) return getRemoteFactory(remote)(pathStr);
470
+ }
391
471
  if (isNode()) {
392
472
  if (path.isAbsolute(pathStr)) {
393
473
  return wrapHandle(new NodeJSDirectoryHandleWrapper(pathStr));
@@ -424,6 +504,7 @@ export const getFileStorage = lazy(async function getFileStorage(): Promise<File
424
504
  });
425
505
  export function resetStorageLocation() {
426
506
  localStorage.removeItem(getFileAPIKey());
507
+ try { localStorage.removeItem(remoteConfigKey()); } catch { /* ignore */ }
427
508
  window.location.reload();
428
509
  }
429
510
 
@@ -0,0 +1,16 @@
1
+ export declare function generatePassword(wordCount: number): string;
2
+ export type RemoteFileServerOptions = {
3
+ root: string;
4
+ port?: number;
5
+ host?: string;
6
+ password?: string;
7
+ logAccess?: boolean;
8
+ };
9
+ export type RemoteFileServerHandle = {
10
+ port: number;
11
+ password: string;
12
+ url: string;
13
+ close: () => Promise<void>;
14
+ };
15
+ export declare function startRemoteFileServer(options: RemoteFileServerOptions): Promise<RemoteFileServerHandle>;
16
+ export declare function runFileHoster(): Promise<void>;