sliftutils 1.2.39 → 1.3.1

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,47 @@
1
+ // Best-effort "only one tab merges this collection at a time" guard, layered ON TOP of the manifest
2
+ // scheme (which guarantees correctness on its own). This is purely an efficiency measure: it stops two
3
+ // tabs doing the same compaction at once and racing to orphan each other's output. It uses
4
+ // localStorage (shared across same-origin tabs) and is a no-op where localStorage is unavailable
5
+ // (Node) — there the manifest backstop alone keeps things correct, which is also what the Node stress
6
+ // tests exercise. localStorage has no atomic compare-and-swap, so we write-then-reread to shrink the
7
+ // race window, and a TTL frees a lock left behind by a tab that crashed or closed mid-merge.
8
+
9
+ const LOCK_TTL_MS = 30 * 1000;
10
+
11
+ function getLocalStorage(): Storage | undefined {
12
+ try {
13
+ return typeof localStorage !== "undefined" ? localStorage : undefined;
14
+ } catch {
15
+ return undefined; // accessing localStorage can throw (e.g. disabled cookies)
16
+ }
17
+ }
18
+
19
+ function lockKey(collection: string): string {
20
+ return "bulkDatabase2-merge:" + collection;
21
+ }
22
+
23
+ // Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which
24
+ // case we proceed and rely on the manifest backstop). Returns false if another tab holds a fresh lock.
25
+ export function tryAcquireMergeLock(collection: string, holderId: string): boolean {
26
+ const ls = getLocalStorage();
27
+ if (!ls) return true;
28
+ const key = lockKey(collection);
29
+ const now = Date.now();
30
+ const existing = ls.getItem(key);
31
+ if (existing) {
32
+ const t = parseInt(existing.slice(existing.lastIndexOf(":") + 1), 10);
33
+ if (Number.isFinite(t) && now - t < LOCK_TTL_MS) return false;
34
+ }
35
+ const token = holderId + ":" + now;
36
+ ls.setItem(key, token);
37
+ // Re-read to catch a racing setItem from another tab (best-effort, not atomic).
38
+ return ls.getItem(key) === token;
39
+ }
40
+
41
+ export function releaseMergeLock(collection: string, holderId: string): void {
42
+ const ls = getLocalStorage();
43
+ if (!ls) return;
44
+ const key = lockKey(collection);
45
+ const existing = ls.getItem(key);
46
+ if (existing && existing.startsWith(holderId + ":")) ls.removeItem(key);
47
+ }
@@ -0,0 +1,25 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
4
+ export declare const STREAM_EXTENSION = ".stream";
5
+ export type StreamEntry = {
6
+ time: number;
7
+ row?: Record<string, unknown>;
8
+ deletedKey?: string;
9
+ };
10
+ export declare function frameRows(entries: {
11
+ time: number;
12
+ row: Record<string, unknown>;
13
+ }[]): Buffer;
14
+ export declare function frameDeletes(entries: {
15
+ time: number;
16
+ key: string;
17
+ }[]): Buffer;
18
+ export declare function parseStream(buffer: Buffer): {
19
+ entries: StreamEntry[];
20
+ badBytes: number;
21
+ };
22
+ export declare function streamReaderFromEntries(entries: StreamEntry[], totalBytes: number): {
23
+ reader: BaseBulkDatabaseReader;
24
+ times: Map<string, number>;
25
+ };
@@ -0,0 +1,128 @@
1
+ import cborx from "cbor-x";
2
+ import { ABSENT, BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
3
+
4
+ // Tier-0 streaming format: an append log of whole-row writes and deletes (row-format, not columnar),
5
+ // so small mutations are a single cheap append instead of rewriting a columnar file. Each block is:
6
+ //
7
+ // [u32 len][CBOR({ t, v }) or CBOR({ t, d })][u32 len]
8
+ //
9
+ // `t` is a per-write unique timestamp (getTimeUnique); `v` is a set row; `d` is a deleted key. Because
10
+ // every thread streams to its own file, the only way to recover the global mutation order — needed
11
+ // for newest-wins — is a per-write timestamp, with ties broken by file name. The trailing length must
12
+ // match the leading one; a torn/incomplete append leaves a mismatched or missing suffix, so we stop
13
+ // there and report trailing bad bytes instead of throwing. structuredClone preserves typed arrays.
14
+
15
+ export const STREAM_EXTENSION = ".stream";
16
+
17
+ const cborEncoder = new cborx.Encoder({ structuredClone: true });
18
+
19
+ export type StreamEntry = { time: number; row?: Record<string, unknown>; deletedKey?: string };
20
+
21
+ function frame(payload: Buffer): Buffer {
22
+ let len = Buffer.alloc(4);
23
+ len.writeUInt32LE(payload.length, 0);
24
+ return Buffer.concat([len, payload, len]);
25
+ }
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.
34
+
35
+ // Times are assigned by the caller (BulkDatabase2) so the exact same timestamp lands on disk, in the
36
+ // in-memory overlay, and in the cross-tab broadcast — keeping the global write order consistent.
37
+ export function frameRows(entries: { time: number; row: Record<string, unknown> }[]): Buffer {
38
+ return Buffer.concat(entries.map(e => frame(Buffer.from(cborEncoder.encode({ t: e.time, v: e.row })))));
39
+ }
40
+
41
+ export function frameDeletes(entries: { time: number; key: string }[]): Buffer {
42
+ return Buffer.concat(entries.map(e => frame(Buffer.from(cborEncoder.encode({ t: e.time, d: e.key })))));
43
+ }
44
+
45
+ export function parseStream(buffer: Buffer): { entries: StreamEntry[]; badBytes: number } {
46
+ let entries: StreamEntry[] = [];
47
+ let pos = 0;
48
+ while (pos + 4 <= buffer.length) {
49
+ let len = buffer.readUInt32LE(pos);
50
+ let payloadStart = pos + 4;
51
+ let suffixStart = payloadStart + len;
52
+ if (suffixStart + 4 > buffer.length) break;
53
+ if (buffer.readUInt32LE(suffixStart) !== len) break;
54
+ let decoded: { t: number; v?: Record<string, unknown>; d?: string } | undefined;
55
+ try {
56
+ decoded = cborEncoder.decode(buffer.subarray(payloadStart, suffixStart));
57
+ } catch {
58
+ break;
59
+ }
60
+ if (decoded && decoded.d !== undefined) entries.push({ time: decoded.t, deletedKey: decoded.d });
61
+ else if (decoded && decoded.v) entries.push({ time: decoded.t, row: decoded.v });
62
+ pos = suffixStart + 4;
63
+ }
64
+ return { entries, badBytes: buffer.length - pos };
65
+ }
66
+
67
+ // Wraps streamed entries (already ordered oldest-first) as a BaseBulkDatabaseReader. Each set MERGES
68
+ // its fields onto the key's current row (so a partial write/update only changes the columns it
69
+ // includes); a delete tombstones the key (exposed via deletedKeys so the join suppresses it in older
70
+ // bulk readers) and resets the merge. A column the merged row never set reads as ABSENT, so the join
71
+ // falls through to older readers for it. Also returns the latest timestamp seen per key (live or
72
+ // deleted), used for cross-tab conflict resolution.
73
+ export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: number): { reader: BaseBulkDatabaseReader; times: Map<string, number> } {
74
+ let byKey = new Map<string, Record<string, unknown>>();
75
+ let deletedKeys = new Set<string>();
76
+ let times = new Map<string, number>();
77
+ for (let entry of entries) {
78
+ if (entry.deletedKey !== undefined) {
79
+ byKey.delete(entry.deletedKey);
80
+ deletedKeys.add(entry.deletedKey);
81
+ times.set(entry.deletedKey, entry.time);
82
+ } else if (entry.row) {
83
+ let key = entry.row.key as string;
84
+ byKey.set(key, { ...byKey.get(key), ...entry.row });
85
+ deletedKeys.delete(key);
86
+ times.set(key, entry.time);
87
+ }
88
+ }
89
+ let keys = [...byKey.keys()];
90
+ let columnNames: string[] = [];
91
+ let seen = new Set<string>();
92
+ for (let row of byKey.values()) {
93
+ for (let field of Object.keys(row)) {
94
+ if (seen.has(field)) continue;
95
+ seen.add(field);
96
+ columnNames.push(field);
97
+ }
98
+ }
99
+ let columns = columnNames.map(column => ({ column, byteSize: 0 }));
100
+ // Per-key tombstone times for the join's delete resolution.
101
+ let deleteTimes = new Map<string, number>();
102
+ for (let key of deletedKeys) deleteTimes.set(key, times.get(key) || 0);
103
+ let timeValues = [...times.values()];
104
+ let reader: BaseBulkDatabaseReader = {
105
+ totalBytes,
106
+ rowCount: keys.length,
107
+ minTime: timeValues.length ? Math.min(...timeValues) : 0,
108
+ maxTime: timeValues.length ? Math.max(...timeValues) : 0,
109
+ keys,
110
+ keyTimes: new Map(keys.map(key => [key, times.get(key) || 0])),
111
+ columns,
112
+ deleteTimes,
113
+ // A key's time is its latest write across all columns (per-key, not per-column). For a column
114
+ // it never set we return ABSENT so the join falls through to an older reader.
115
+ async getColumn(column) {
116
+ return keys.map(key => {
117
+ let row = byKey.get(key);
118
+ return { key, value: row && column in row ? row[column] : ABSENT, time: times.get(key) || 0 };
119
+ });
120
+ },
121
+ async getSingleField(key, column) {
122
+ let row = byKey.get(key);
123
+ if (!row || !(column in row)) return ABSENT;
124
+ return { value: row[column], time: times.get(key) || 0 };
125
+ },
126
+ };
127
+ return { reader, times };
128
+ }
@@ -0,0 +1,9 @@
1
+ export type RemoteWrite = {
2
+ key: string;
3
+ time: number;
4
+ deleted?: boolean;
5
+ value?: unknown;
6
+ };
7
+ export declare function isSyncSupported(): boolean;
8
+ export declare function connect(collection: string, onWrite: (write: RemoteWrite) => void): Promise<RemoteWrite[]>;
9
+ export declare function broadcast(collection: string, write: RemoteWrite): void;
@@ -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();