sliftutils 1.2.39 → 1.3.0

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.
@@ -0,0 +1,81 @@
1
+ import { isNode } from "typesafecss";
2
+
3
+ // Browser-side cross-tab write sync for BulkDatabase2, over BroadcastChannel (one channel per
4
+ // collection, same-origin tabs). It does NOT persist anything — each tab writes to disk itself; this
5
+ // just relays live writes to other open tabs and, when a tab starts up, asks peers for writes they've
6
+ // made recently that may not be on disk yet. No-op in Node / where BroadcastChannel is unavailable, so
7
+ // BulkDatabase2 can call these unconditionally. This is an optional feature, so there is no fallback.
8
+
9
+ export type RemoteWrite = { key: string; time: number; deleted?: boolean; value?: unknown };
10
+
11
+ // Writes older than this are assumed already flushed to disk (a freshly-opened tab gets those by
12
+ // reading disk), so a tab only replays writes newer than this when a peer says hello.
13
+ const RECENT_WINDOW_MS = 60_000;
14
+
15
+ type Channel = {
16
+ bc: BroadcastChannel;
17
+ subscribers: ((write: RemoteWrite) => void)[];
18
+ // This tab's own recent writes, kept so it can answer another tab's "hello".
19
+ recent: RemoteWrite[];
20
+ };
21
+
22
+ const channels = new Map<string, Channel>();
23
+
24
+ export function isSyncSupported(): boolean {
25
+ return !isNode() && typeof BroadcastChannel !== "undefined";
26
+ }
27
+
28
+ function pruneRecent(channel: Channel) {
29
+ const cutoff = Date.now() - RECENT_WINDOW_MS;
30
+ channel.recent = channel.recent.filter(w => w.time >= cutoff);
31
+ }
32
+
33
+ function deliver(channel: Channel, write: RemoteWrite) {
34
+ for (const sub of channel.subscribers) sub(write);
35
+ }
36
+
37
+ function ensure(collection: string): Channel | undefined {
38
+ if (!isSyncSupported()) return undefined;
39
+ let channel = channels.get(collection);
40
+ if (channel) return channel;
41
+ // BroadcastChannel never delivers a message back to the instance that sent it, so a tab never
42
+ // hears its own writes — only the other open tabs do.
43
+ const bc = new BroadcastChannel(`bulkDatabase2:${collection}`);
44
+ const created: Channel = { bc, subscribers: [], recent: [] };
45
+ bc.onmessage = (event: MessageEvent) => {
46
+ const msg = event.data as { type: string; write?: RemoteWrite; writes?: RemoteWrite[] };
47
+ if (!msg) return;
48
+ if (msg.type === "write" && msg.write) {
49
+ deliver(created, msg.write);
50
+ } else if (msg.type === "recent" && msg.writes) {
51
+ for (const write of msg.writes) deliver(created, write);
52
+ } else if (msg.type === "hello") {
53
+ // Another tab just started; replay our recent writes so it doesn't miss any. The reply goes
54
+ // to every tab, but peers that already have a write ignore it (its timestamp isn't newer
55
+ // than what they hold), so the redundant broadcast is harmless.
56
+ pruneRecent(created);
57
+ if (created.recent.length) created.bc.postMessage({ type: "recent", writes: created.recent });
58
+ }
59
+ };
60
+ channels.set(collection, created);
61
+ return created;
62
+ }
63
+
64
+ // Subscribe to remote writes for a collection. Recent writes from already-open tabs arrive through the
65
+ // same onWrite callback (as the reply to our hello), so the returned array is always empty — it's kept
66
+ // only for API compatibility with callers that await it.
67
+ export function connect(collection: string, onWrite: (write: RemoteWrite) => void): Promise<RemoteWrite[]> {
68
+ const channel = ensure(collection);
69
+ if (!channel) return Promise.resolve([]);
70
+ channel.subscribers.push(onWrite);
71
+ channel.bc.postMessage({ type: "hello" });
72
+ return Promise.resolve([]);
73
+ }
74
+
75
+ export function broadcast(collection: string, write: RemoteWrite): void {
76
+ const channel = ensure(collection);
77
+ if (!channel) return;
78
+ channel.recent.push(write);
79
+ pruneRecent(channel);
80
+ channel.bc.postMessage({ type: "write", write });
81
+ }
@@ -6,6 +6,8 @@ import { TransactionStorage } from "./TransactionStorage";
6
6
  export declare class DiskCollection<T> implements IStorageSync<T> {
7
7
  private collectionName;
8
8
  private config?;
9
+ static getForceNoPrompt(): boolean;
10
+ static setForceNoPrompt(forceNoPrompt: boolean): void;
9
11
  constructor(collectionName: string, config?: {
10
12
  writeDelay?: number | undefined;
11
13
  cbor?: boolean | undefined;
@@ -11,7 +11,23 @@ import { PrivateFileSystemStorage } from "./PrivateFileSystemStorage";
11
11
  import { isInChromeExtension, isInChromeExtensionBackground } from "../misc/environment";
12
12
  import { CBORStorage } from "./CBORStorage";
13
13
 
14
+ const FORCE_NO_PROMPT_KEY = "DiskCollection_forceNoPrompt";
15
+
14
16
  export class DiskCollection<T> implements IStorageSync<T> {
17
+ // Globally forces every DiskCollection into noPrompt mode (browser only), overriding per-collection config.
18
+ public static getForceNoPrompt(): boolean {
19
+ if (isNode()) return false;
20
+ return !!localStorage.getItem(FORCE_NO_PROMPT_KEY);
21
+ }
22
+ public static setForceNoPrompt(forceNoPrompt: boolean) {
23
+ if (isNode()) return;
24
+ if (forceNoPrompt) {
25
+ localStorage.setItem(FORCE_NO_PROMPT_KEY, "1");
26
+ } else {
27
+ localStorage.removeItem(FORCE_NO_PROMPT_KEY);
28
+ }
29
+ }
30
+
15
31
  constructor(
16
32
  private collectionName: string,
17
33
  private config?: {
@@ -29,7 +45,7 @@ export class DiskCollection<T> implements IStorageSync<T> {
29
45
  // If a Chrome extension, just return null.
30
46
  if (isInChromeExtensionBackground()) return null as any;
31
47
  let curCollection: IStorageRaw;
32
- if (this.config?.noPrompt && !isNode()) {
48
+ if (!isNode() && (this.config?.noPrompt || DiskCollection.getForceNoPrompt())) {
33
49
  curCollection = await new PrivateFileSystemStorage(`collections/${this.collectionName}`);
34
50
  } else {
35
51
  let fileStorage = await getFileStorage();
@@ -70,6 +70,52 @@ type DirectoryWrapper = {
70
70
  ]>;
71
71
  };
72
72
  export declare function setFileAPIKey(key: string): void;
73
+ export declare class NodeJSFileHandleWrapper implements FileWrapper {
74
+ private filePath;
75
+ constructor(filePath: string);
76
+ getFile(): Promise<{
77
+ size: number;
78
+ lastModified: number;
79
+ arrayBuffer: () => Promise<ArrayBuffer>;
80
+ slice: (start: number, end: number) => {
81
+ arrayBuffer: () => Promise<ArrayBuffer>;
82
+ };
83
+ }>;
84
+ createWritable(config?: {
85
+ keepExistingData?: boolean;
86
+ }): Promise<{
87
+ seek: (offset: number) => Promise<void>;
88
+ write: (value: Buffer) => Promise<void>;
89
+ close: () => Promise<void>;
90
+ }>;
91
+ }
92
+ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
93
+ private rootPath;
94
+ constructor(rootPath: string);
95
+ removeEntry(key: string, options?: {
96
+ recursive?: boolean;
97
+ }): Promise<void>;
98
+ getFileHandle(key: string, options?: {
99
+ create?: boolean;
100
+ }): Promise<FileWrapper>;
101
+ getDirectoryHandle(key: string, options?: {
102
+ create?: boolean;
103
+ }): Promise<DirectoryWrapper>;
104
+ [Symbol.asyncIterator](): AsyncIterableIterator<[
105
+ string,
106
+ {
107
+ kind: "file";
108
+ name: string;
109
+ getFile(): Promise<FileWrapper>;
110
+ } | {
111
+ kind: "directory";
112
+ name: string;
113
+ getDirectoryHandle(key: string, options?: {
114
+ create?: boolean;
115
+ }): Promise<DirectoryWrapper>;
116
+ }
117
+ ]>;
118
+ }
73
119
  export declare const getDirectoryHandle: {
74
120
  (): Promise<DirectoryWrapper>;
75
121
  reset(): void;
@@ -83,6 +129,14 @@ export declare const getFileStorageNested: {
83
129
  getAllKeys(): string[];
84
130
  get(key: string): Promise<FileStorage> | undefined;
85
131
  };
132
+ export declare const getFileStorageNested2: {
133
+ (key: string): Promise<FileStorage>;
134
+ clear(key: string): void;
135
+ clearAll(): void;
136
+ forceSet(key: string, value: Promise<FileStorage>): void;
137
+ getAllKeys(): string[];
138
+ get(key: string): Promise<FileStorage> | undefined;
139
+ };
86
140
  export declare const getFileStorage: {
87
141
  (): Promise<FileStorage>;
88
142
  reset(): void;
@@ -98,4 +152,6 @@ export type NestedFileStorage = {
98
152
  export type FileStorage = IStorageRaw & {
99
153
  folder: NestedFileStorage;
100
154
  };
155
+ export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
156
+ export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
101
157
  export {};
@@ -96,7 +96,7 @@ class DirectoryPrompter extends preact.Component {
96
96
  }
97
97
  }
98
98
 
99
- class NodeJSFileHandleWrapper implements FileWrapper {
99
+ export class NodeJSFileHandleWrapper implements FileWrapper {
100
100
  constructor(private filePath: string) {
101
101
  }
102
102
 
@@ -159,7 +159,7 @@ class NodeJSFileHandleWrapper implements FileWrapper {
159
159
  }
160
160
  }
161
161
 
162
- class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
162
+ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
163
163
  constructor(private rootPath: string) {
164
164
  }
165
165
 
@@ -241,6 +241,7 @@ class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
241
241
 
242
242
 
243
243
  // NOTE: Blocks until the user provides a directory
244
+ // NOTE: If you want to override the location, you can explicitly call getDirectoryHandle.set with your own directory wrapper. Which if your server side can be the Node.js directory handle wrapper, or if your client side it can just be a pointer from tryToLoadPointer/fileSystemPointer.ts.
244
245
  export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
245
246
  if (isNode()) {
246
247
  return new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
@@ -378,6 +379,33 @@ export const getFileStorageNested = cache(async function getFileStorage(path: st
378
379
  }
379
380
  return wrapHandle(base);
380
381
  });
382
+ // Supports if the user selects the folder that contains the data folder or the data folder directly. If pathStr is an absolute path (and we're in nodejs) we use it directly.
383
+ export const getFileStorageNested2 = cache(async function getFileStorage(pathStr: string): Promise<FileStorage> {
384
+ let base: DirectoryWrapper;
385
+ pathStr = pathStr.replaceAll("\\", "/");
386
+ if (isNode()) {
387
+ if (path.isAbsolute(pathStr)) {
388
+ return wrapHandle(new NodeJSDirectoryHandleWrapper(pathStr));
389
+ }
390
+ base = new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
391
+ } else {
392
+ base = await getDirectoryHandle();
393
+ let dirs: string[] = [];
394
+ for await (const [name, entry] of base) {
395
+ if (entry.kind === "directory") {
396
+ dirs.push(name);
397
+ }
398
+ }
399
+ if (dirs.includes(".git") || dirs.includes("data")) {
400
+ base = await base.getDirectoryHandle("data", { create: true });
401
+ }
402
+ }
403
+ for (let part of pathStr.split("/")) {
404
+ if (!part) continue;
405
+ base = await base.getDirectoryHandle(part, { create: true });
406
+ }
407
+ return wrapHandle(base);
408
+ });
381
409
  export const getFileStorage = lazy(async function getFileStorage(): Promise<FileStorage> {
382
410
  if (USE_INDEXED_DB) {
383
411
  return await getFileStorageIndexDB();
@@ -434,40 +462,78 @@ async function fixedGetFileHandle(config: {
434
462
  return await config.handle.getFileHandle(config.key, { create: true });
435
463
  }
436
464
 
465
+ // A file that genuinely does not exist. We must NOT retry these, otherwise reading many missing
466
+ // files (a common pattern) gets catastrophically slow.
467
+ function isMissingError(error: unknown): boolean {
468
+ let name = (error as { name?: string })?.name;
469
+ let code = (error as { code?: string })?.code;
470
+ return name === "NotFoundError" || code === "ENOENT";
471
+ }
472
+
473
+ const MAX_READ_RETRIES = 6;
474
+
475
+ // The browser File System Access API can transiently fail reads under heavy load (it appears to
476
+ // throttle when a page issues many reads at once). Those failures surface as errors OTHER than
477
+ // "not found", so we retry them with backoff. A real missing file (NotFoundError / ENOENT) returns
478
+ // undefined immediately with no retry.
479
+ async function readWithRetry<T>(label: string, key: string, read: () => Promise<T>): Promise<T | undefined> {
480
+ let backoff = 25;
481
+ for (let attempt = 0; ; attempt++) {
482
+ try {
483
+ return await read();
484
+ } catch (error) {
485
+ if (isMissingError(error)) return undefined;
486
+ let name = (error as { name?: string })?.name || "Error";
487
+ let message = (error as { message?: string })?.message || String(error);
488
+ if (attempt >= MAX_READ_RETRIES) {
489
+ console.warn(`${label} gave up on ${JSON.stringify(key)} after ${attempt} retries (${name}): ${message.slice(0, 200)}`);
490
+ return undefined;
491
+ }
492
+ console.warn(`${label} retrying ${JSON.stringify(key)} (attempt ${attempt + 1}, ${name}): ${message.slice(0, 200)}`);
493
+ await new Promise(resolve => setTimeout(resolve, backoff));
494
+ backoff = Math.min(backoff * 2, 1000);
495
+ }
496
+ }
497
+ }
498
+
437
499
  function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
438
500
  return {
439
501
  async getInfo(key: string) {
440
- try {
502
+ return readWithRetry("getInfo", key, async () => {
441
503
  const file = await handle.getFileHandle(key);
442
504
  const fileContent = await file.getFile();
443
505
  return {
444
506
  size: fileContent.size,
445
507
  lastModified: fileContent.lastModified,
446
508
  };
447
- } catch (error) {
448
- return undefined;
449
- }
509
+ });
450
510
  },
451
511
  async get(key: string): Promise<Buffer | undefined> {
452
- try {
512
+ return readWithRetry("get", key, async () => {
453
513
  const file = await handle.getFileHandle(key);
454
514
  const fileContent = await file.getFile();
455
515
  const arrayBuffer = await fileContent.arrayBuffer();
516
+ // Under load the FS Access API can resolve with a truncated buffer and no error.
517
+ // Treat a short read as transient so readWithRetry retries it.
518
+ if (arrayBuffer.byteLength !== fileContent.size) {
519
+ throw Object.assign(new Error(`Short read: got ${arrayBuffer.byteLength} of ${fileContent.size} bytes`), { name: "ShortReadError" });
520
+ }
456
521
  return Buffer.from(arrayBuffer);
457
- } catch (error) {
458
- return undefined;
459
- }
522
+ });
460
523
  },
461
524
 
462
525
  async getRange(key: string, config: { start: number; end: number }): Promise<Buffer | undefined> {
463
- try {
526
+ return readWithRetry("getRange", key, async () => {
464
527
  const file = await handle.getFileHandle(key);
465
528
  const fileContent = await file.getFile();
529
+ const clampedStart = Math.min(Math.max(config.start, 0), fileContent.size);
530
+ const clampedEnd = Math.min(Math.max(config.end, clampedStart), fileContent.size);
466
531
  const arrayBuffer = await fileContent.slice(config.start, config.end).arrayBuffer();
532
+ if (arrayBuffer.byteLength !== clampedEnd - clampedStart) {
533
+ throw Object.assign(new Error(`Short range read: got ${arrayBuffer.byteLength} of ${clampedEnd - clampedStart} bytes`), { name: "ShortReadError" });
534
+ }
467
535
  return Buffer.from(arrayBuffer);
468
- } catch (error) {
469
- return undefined;
470
- }
536
+ });
471
537
  },
472
538
 
473
539
  async append(key: string, value: Buffer): Promise<void> {
@@ -496,10 +562,18 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
496
562
 
497
563
  async getKeys(includeFolders: boolean = false): Promise<string[]> {
498
564
  const keys: string[] = [];
499
- for await (const [name, entry] of handle) {
500
- if (entry.kind === "file" || includeFolders) {
501
- keys.push(entry.name);
565
+ try {
566
+ for await (const [name, entry] of handle) {
567
+ if (entry.kind === "file" || includeFolders) {
568
+ keys.push(entry.name);
569
+ }
502
570
  }
571
+ } catch (error) {
572
+ let name = (error as { name?: string })?.name || "Error";
573
+ let message = (error as { message?: string })?.message || String(error);
574
+ // A failure mid-iteration would silently truncate the listing, so surface it loudly.
575
+ console.error(`getKeys directory iteration failed after ${keys.length} entries (${name}): ${message.slice(0, 300)}`);
576
+ throw error;
503
577
  }
504
578
  return keys;
505
579
  },
@@ -544,14 +618,14 @@ function wrapHandleNested(handle: DirectoryWrapper): NestedFileStorage {
544
618
  };
545
619
  }
546
620
 
547
- function wrapHandle(handle: DirectoryWrapper): FileStorage {
621
+ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
548
622
  return {
549
623
  ...wrapHandleFiles(handle),
550
624
  folder: wrapHandleNested(handle),
551
625
  };
552
626
  }
553
627
 
554
- async function tryToLoadPointer(pointer: string) {
628
+ export async function tryToLoadPointer(pointer: string) {
555
629
  let result = await getFileSystemPointer({ pointer });
556
630
  if (!result) return;
557
631
  let handle = await result?.onUserActivation();
package/yarn.lock CHANGED
@@ -1940,10 +1940,10 @@ slash@^3.0.0:
1940
1940
  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
1941
1941
  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
1942
1942
 
1943
- socket-function@^1.1.30:
1944
- version "1.1.30"
1945
- resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.1.30.tgz#adeb9614996e727518e2abdc7a1aa90544d669cd"
1946
- integrity sha512-EcqNpM8i0icBr2iU80aovXI05aUBrB4P4e9U7EQyju68TGAMagdA4OQXi+V1MHUipH/3nIdw3UAVYv+kFwZwnQ==
1943
+ socket-function@^1.1.42:
1944
+ version "1.1.42"
1945
+ resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.1.42.tgz#79694906fe37c77da720f0fcc46a812021349062"
1946
+ integrity sha512-+nhKi73LNv8m99w/sShaQbxQbUA1qgO7QvP0PX7MdV9SvhAbnOY9O4siaqTfqtJy5cMuX8Xnbfz/pptvvPDTaA==
1947
1947
  dependencies:
1948
1948
  "@types/pako" "^2.0.3"
1949
1949
  "@types/ws" "^8.5.3"