@abraca/dabra 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -126,6 +126,7 @@ declare class EventEmitter {
126
126
  [key: string]: Function[];
127
127
  };
128
128
  on(event: string, fn: Function): this;
129
+ once(event: string, fn: Function): this;
129
130
  protected emit(event: string, ...args: any): this;
130
131
  off(event: string, fn?: Function): this;
131
132
  removeAllListeners(): void;
@@ -256,6 +257,8 @@ declare class AbracadabraClient {
256
257
  get token(): string | null;
257
258
  set token(value: string | null);
258
259
  get isAuthenticated(): boolean;
260
+ /** Check if the current JWT token is present and not expired. */
261
+ isTokenValid(): boolean;
259
262
  /** Derives ws:// or wss:// URL from the http(s) base URL. */
260
263
  get wsUrl(): string;
261
264
  /** Register a new user with password. */
@@ -427,6 +430,7 @@ declare class AbracadabraClient {
427
430
  */
428
431
  getIceServers(): Promise<RTCIceServer[]>;
429
432
  private request;
433
+ private requestOrNull;
430
434
  private toError;
431
435
  private loadPersistedToken;
432
436
  private persistToken;
@@ -508,6 +512,7 @@ declare class CryptoIdentityKeystore {
508
512
  //#region packages/provider/src/DocKeyManager.d.ts
509
513
  declare class DocKeyManager {
510
514
  private cache;
515
+ private static readonly CACHE_TTL;
511
516
  /** Generate a new random AES-256-GCM document key. */
512
517
  static generateDocKey(): Promise<CryptoKey>;
513
518
  /**
@@ -655,6 +660,10 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
655
660
  * If offline the event is queued in IndexedDB and replayed on reconnect.
656
661
  */
657
662
  private registerSubdoc;
663
+ /** Get a loaded child provider by ID, or null if not yet loaded. */
664
+ getChild(childId: string): AbracadabraProvider | null;
665
+ /** Check if a child provider is already loaded. */
666
+ hasChild(childId: string): boolean;
658
667
  /**
659
668
  * Create (or return cached) a child AbracadabraProvider for a given
660
669
  * child document id. Each child opens its own WebSocket connection because
@@ -1239,10 +1248,14 @@ declare class AbracadabraBaseProvider extends EventEmitter {
1239
1248
  isSynced: boolean;
1240
1249
  unsyncedChanges: number;
1241
1250
  isAuthenticated: boolean;
1251
+ /** Current WebSocket connection status. */
1252
+ get connectionStatus(): WebSocketStatus;
1242
1253
  authorizedScope: AuthorizedScope | undefined;
1243
1254
  manageSocket: boolean;
1244
1255
  private _isAttached;
1245
- intervals: any;
1256
+ intervals: {
1257
+ forceSync: ReturnType<typeof setInterval> | null;
1258
+ };
1246
1259
  constructor(configuration: AbracadabraBaseProviderConfiguration);
1247
1260
  boundDocumentUpdateHandler: (update: Uint8Array, origin: any) => void;
1248
1261
  boundAwarenessUpdateHandler: ({
@@ -1375,7 +1388,7 @@ declare class FileBlobStore extends EventEmitter {
1375
1388
  private readonly _notFound;
1376
1389
  private static readonly NOT_FOUND_TTL;
1377
1390
  /** Prevents concurrent flush runs. */
1378
- private _flushing;
1391
+ private _flushPromise;
1379
1392
  private readonly _onlineHandler;
1380
1393
  constructor(serverOrigin: string, client?: AbracadabraClient | null);
1381
1394
  private getDb;
@@ -1399,6 +1412,24 @@ declare class FileBlobStore extends EventEmitter {
1399
1412
  * Use this to re-upload a file after a page reload.
1400
1413
  */
1401
1414
  getBlob(docId: string, uploadId: string): Promise<Blob | null>;
1415
+ /** Return metadata for all cached blobs (for storage stats). */
1416
+ getAllCachedEntries(): Promise<Array<{
1417
+ docId: string;
1418
+ uploadId: string;
1419
+ filename: string;
1420
+ mimeType: string;
1421
+ size: number;
1422
+ cachedAt: number;
1423
+ }>>;
1424
+ /** Revoke all object URLs and clear the entire blob cache from IDB. */
1425
+ clearAllBlobs(): Promise<void>;
1426
+ /**
1427
+ * Revoke the in-memory object URL without touching the IDB cache.
1428
+ * The next call to getBlobUrl() will re-create a fresh URL from IDB.
1429
+ * Use this when an <img> @error fires — the blob data is fine, only
1430
+ * the object URL reference is stale.
1431
+ */
1432
+ invalidateUrl(docId: string, uploadId: string): void;
1402
1433
  /** Revoke the object URL and remove the blob from cache. */
1403
1434
  evictBlob(docId: string, uploadId: string): Promise<void>;
1404
1435
  /**
@@ -1415,6 +1446,7 @@ declare class FileBlobStore extends EventEmitter {
1415
1446
  * Entries that fail are marked with status "error" and left in the queue.
1416
1447
  */
1417
1448
  flushQueue(): Promise<void>;
1449
+ private _doFlush;
1418
1450
  private _updateQueueEntry;
1419
1451
  destroy(): void;
1420
1452
  }
@@ -1582,7 +1614,14 @@ declare class BackgroundSyncManager extends EventEmitter {
1582
1614
  private readonly semaphore;
1583
1615
  private readonly syncStates;
1584
1616
  private _destroyed;
1617
+ private _initPromise;
1585
1618
  constructor(rootProvider: AbracadabraProvider, client: AbracadabraClient, fileBlobStore?: FileBlobStore | null, opts?: BackgroundSyncManagerOptions);
1619
+ /**
1620
+ * Load persisted sync states from IndexedDB into the in-memory map.
1621
+ * Called automatically by syncAll() / syncDoc(); safe to call concurrently.
1622
+ */
1623
+ init(): Promise<void>;
1624
+ private _loadPersistedStates;
1586
1625
  /** Sync all documents in the root tree. */
1587
1626
  syncAll(): Promise<void>;
1588
1627
  /** Sync a single document by ID. */
@@ -1709,7 +1748,7 @@ declare class DataChannelRouter extends EventEmitter {
1709
1748
  * Send data on a named channel, encrypting if E2EE is active.
1710
1749
  * Falls back to plaintext if no encryptor is set or for exempt channels.
1711
1750
  */
1712
- send(name: string, data: Uint8Array): Promise<void>;
1751
+ send(name: string, data: Uint8Array): Promise<boolean>;
1713
1752
  private registerChannel;
1714
1753
  close(): void;
1715
1754
  destroy(): void;
@@ -1997,7 +2036,9 @@ declare class SignalingSocket extends EventEmitter {
1997
2036
  isConnected: boolean;
1998
2037
  constructor(configuration: SignalingSocketConfiguration);
1999
2038
  private getToken;
2039
+ private _connectPromise;
2000
2040
  connect(): Promise<void>;
2041
+ private _doConnect;
2001
2042
  private createConnection;
2002
2043
  private handleMessage;
2003
2044
  private sendRaw;
@@ -2048,6 +2089,7 @@ declare class YjsDataChannel {
2048
2089
  private readonly document;
2049
2090
  private readonly awareness;
2050
2091
  private readonly router;
2092
+ isSynced: boolean;
2051
2093
  private docUpdateHandler;
2052
2094
  private awarenessUpdateHandler;
2053
2095
  private channelOpenHandler;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -29,6 +29,7 @@ import type {
29
29
  onStatusParameters,
30
30
  onSyncedParameters,
31
31
  onUnsyncedChangesParameters,
32
+ WebSocketStatus,
32
33
  } from "./types.ts";
33
34
 
34
35
  export type AbracadabraBaseProviderConfiguration = Required<
@@ -137,6 +138,11 @@ export class AbracadabraBaseProvider extends EventEmitter {
137
138
 
138
139
  isAuthenticated = false;
139
140
 
141
+ /** Current WebSocket connection status. */
142
+ get connectionStatus(): WebSocketStatus {
143
+ return this.configuration.websocketProvider.status;
144
+ }
145
+
140
146
  authorizedScope: AuthorizedScope | undefined = undefined;
141
147
 
142
148
  // @internal
@@ -144,7 +150,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
144
150
 
145
151
  private _isAttached = false;
146
152
 
147
- intervals: any = {
153
+ intervals: { forceSync: ReturnType<typeof setInterval> | null } = {
148
154
  forceSync: null,
149
155
  };
150
156
 
@@ -78,6 +78,18 @@ export class AbracadabraClient {
78
78
  return this._token !== null;
79
79
  }
80
80
 
81
+ /** Check if the current JWT token is present and not expired. */
82
+ isTokenValid(): boolean {
83
+ if (!this._token) return false;
84
+ try {
85
+ const [, payload] = this._token.split(".");
86
+ const { exp } = JSON.parse(atob(payload));
87
+ return typeof exp === "number" && exp * 1000 > Date.now();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
81
93
  /** Derives ws:// or wss:// URL from the http(s) base URL. */
82
94
  get wsUrl(): string {
83
95
  return this.baseUrl
@@ -202,17 +214,10 @@ export class AbracadabraClient {
202
214
 
203
215
  /** Get the caller's key envelope for a document (for decrypting the DocKey). */
204
216
  async getMyKeyEnvelope(docId: string): Promise<{ encrypted_key: string; key_epoch: number } | null> {
205
- try {
206
- return await this.request<{ encrypted_key: string; key_epoch: number }>(
207
- "GET",
208
- `/docs/${encodeURIComponent(docId)}/key-envelope`,
209
- );
210
- } catch (e: unknown) {
211
- if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
212
- return null;
213
- }
214
- throw e;
215
- }
217
+ return this.requestOrNull<{ encrypted_key: string; key_epoch: number }>(
218
+ "GET",
219
+ `/docs/${encodeURIComponent(docId)}/key-envelope`,
220
+ );
216
221
  }
217
222
 
218
223
  /** Upload key envelopes for a document (Owner only). */
@@ -225,18 +230,11 @@ export class AbracadabraClient {
225
230
 
226
231
  /** Get the X25519 public key for a user. */
227
232
  async getUserX25519Key(userId: string): Promise<string | null> {
228
- try {
229
- const res = await this.request<{ x25519_key: string }>(
230
- "GET",
231
- `/users/${encodeURIComponent(userId)}/x25519-key`,
232
- );
233
- return res.x25519_key;
234
- } catch (e: unknown) {
235
- if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
236
- return null;
237
- }
238
- throw e;
239
- }
233
+ const res = await this.requestOrNull<{ x25519_key: string }>(
234
+ "GET",
235
+ `/users/${encodeURIComponent(userId)}/x25519-key`,
236
+ );
237
+ return res?.x25519_key ?? null;
240
238
  }
241
239
 
242
240
  /** List all non-revoked keys for a user (Owner/Admin or self). */
@@ -365,13 +363,16 @@ export class AbracadabraClient {
365
363
  "GET",
366
364
  `/docs/${encodeURIComponent(docId)}/effective-permissions`,
367
365
  );
368
- } catch {
369
- // Fallback for older servers that don't support this endpoint
370
- const perms = await this.listPermissions(docId);
371
- return {
372
- permissions: perms.map((p) => ({ ...p, source: "direct" as const })),
373
- default_role: "viewer",
374
- };
366
+ } catch (e: unknown) {
367
+ // Fallback for older servers that don't support this endpoint (404 only)
368
+ if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
369
+ const perms = await this.listPermissions(docId);
370
+ return {
371
+ permissions: perms.map((p) => ({ ...p, source: "direct" as const })),
372
+ default_role: "viewer",
373
+ };
374
+ }
375
+ throw e;
375
376
  }
376
377
  }
377
378
 
@@ -508,14 +509,7 @@ export class AbracadabraClient {
508
509
 
509
510
  /** Get the hub space, or null if none is configured. */
510
511
  async getHubSpace(): Promise<SpaceMeta | null> {
511
- try {
512
- return await this.request<SpaceMeta>("GET", "/spaces/hub", { auth: false });
513
- } catch (e: unknown) {
514
- if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
515
- return null;
516
- }
517
- throw e;
518
- }
512
+ return this.requestOrNull<SpaceMeta>("GET", "/spaces/hub", { auth: false });
519
513
  }
520
514
 
521
515
  /** Create a new space (auth required). */
@@ -588,7 +582,7 @@ export class AbracadabraClient {
588
582
  headers["Authorization"] = `Bearer ${this._token}`;
589
583
  }
590
584
 
591
- const init: RequestInit = { method, headers };
585
+ const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) };
592
586
 
593
587
  if (opts?.body !== undefined) {
594
588
  headers["Content-Type"] = "application/json";
@@ -598,7 +592,7 @@ export class AbracadabraClient {
598
592
  const res = await this._fetch(`${this.baseUrl}${path}`, init);
599
593
 
600
594
  if (!res.ok) {
601
- throw await this.toError(res);
595
+ throw await this.toError(res, method, path);
602
596
  }
603
597
 
604
598
  // 204 No Content
@@ -609,7 +603,22 @@ export class AbracadabraClient {
609
603
  return res.json() as Promise<T>;
610
604
  }
611
605
 
612
- private async toError(res: Response): Promise<Error> {
606
+ private async requestOrNull<T>(
607
+ method: string,
608
+ path: string,
609
+ opts?: { body?: unknown; auth?: boolean },
610
+ ): Promise<T | null> {
611
+ try {
612
+ return await this.request<T>(method, path, opts);
613
+ } catch (e: unknown) {
614
+ if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
615
+ return null;
616
+ }
617
+ throw e;
618
+ }
619
+ }
620
+
621
+ private async toError(res: Response, method?: string, path?: string): Promise<Error> {
613
622
  let message: string;
614
623
  try {
615
624
  const body = await res.json() as { error?: string };
@@ -617,7 +626,8 @@ export class AbracadabraClient {
617
626
  } catch {
618
627
  message = res.statusText;
619
628
  }
620
- const err = new Error(message);
629
+ const prefix = method && path ? `${method} ${path}: ` : "";
630
+ const err = new Error(`${prefix}${message} (${res.status})`);
621
631
  (err as any).status = res.status;
622
632
  return err;
623
633
  }
@@ -155,7 +155,12 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
155
155
  this.restorePermissionSnapshot();
156
156
 
157
157
  // Pre-populate the Y.Doc from the local snapshot so the UI works offline.
158
- this.ready = this._initFromOfflineStore();
158
+ this.ready = Promise.race([
159
+ this._initFromOfflineStore(),
160
+ new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
161
+ ]).catch((err) => {
162
+ this.emit("error", { message: `Offline store init failed: ${err?.message ?? err}` });
163
+ });
159
164
  }
160
165
 
161
166
  // ── Server origin derivation ──────────────────────────────────────────────
@@ -337,7 +342,9 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
337
342
 
338
343
  // Intercept auth_challenge before subdoc handling.
339
344
  if (msg.type === "auth_challenge" && msg.challenge && msg.expiresAt) {
340
- this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch(() => null);
345
+ this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch((err) => {
346
+ this.emit("authenticationFailed", { reason: `Auth challenge error: ${err?.message ?? err}` });
347
+ });
341
348
  return;
342
349
  }
343
350
 
@@ -407,6 +414,16 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
407
414
  }
408
415
  }
409
416
 
417
+ /** Get a loaded child provider by ID, or null if not yet loaded. */
418
+ getChild(childId: string): AbracadabraProvider | null {
419
+ return this.childProviders.get(childId) ?? null;
420
+ }
421
+
422
+ /** Check if a child provider is already loaded. */
423
+ hasChild(childId: string): boolean {
424
+ return this.childProviders.has(childId);
425
+ }
426
+
410
427
  /**
411
428
  * Create (or return cached) a child AbracadabraProvider for a given
412
429
  * child document id. Each child opens its own WebSocket connection because
@@ -409,7 +409,11 @@ export class AbracadabraWS extends EventEmitter {
409
409
  const message = new IncomingMessage(event.data);
410
410
  const documentName = message.peekVarString();
411
411
 
412
- this.configuration.providerMap.get(documentName)?.onMessage(event);
412
+ try {
413
+ this.configuration.providerMap.get(documentName)?.onMessage(event);
414
+ } catch (err) {
415
+ console.error(`[AbracadabraWS] Provider onMessage error for "${documentName}":`, err);
416
+ }
413
417
  }
414
418
 
415
419
  resolveConnectionAttempt() {
@@ -79,6 +79,7 @@ export class BackgroundSyncManager extends EventEmitter {
79
79
  private readonly syncStates = new Map<string, DocSyncState>();
80
80
 
81
81
  private _destroyed = false;
82
+ private _initPromise: Promise<void> | null = null;
82
83
 
83
84
  constructor(
84
85
  rootProvider: AbracadabraProvider,
@@ -108,15 +109,45 @@ export class BackgroundSyncManager extends EventEmitter {
108
109
 
109
110
  // ── Public API ────────────────────────────────────────────────────────────
110
111
 
112
+ /**
113
+ * Load persisted sync states from IndexedDB into the in-memory map.
114
+ * Called automatically by syncAll() / syncDoc(); safe to call concurrently.
115
+ */
116
+ async init(): Promise<void> {
117
+ if (!this._initPromise) {
118
+ this._initPromise = this._loadPersistedStates();
119
+ }
120
+ return this._initPromise;
121
+ }
122
+
123
+ private async _loadPersistedStates(): Promise<void> {
124
+ try {
125
+ const states = await this.persistence.getAllStates();
126
+ for (const state of states) {
127
+ this.syncStates.set(state.docId, state);
128
+ }
129
+ } catch {
130
+ // IDB unavailable — proceed with empty map (full sync fallback)
131
+ }
132
+ }
133
+
111
134
  /** Sync all documents in the root tree. */
112
135
  async syncAll(): Promise<void> {
113
136
  if (this._destroyed) return;
114
137
 
138
+ await this.init();
139
+
115
140
  const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
116
141
  const entries = Array.from(treeMap.entries()) as Array<[string, any]>;
117
142
 
118
143
  if (entries.length === 0) return;
119
144
 
145
+ // Build updatedAt lookup for skip-unchanged logic
146
+ const updatedAtMap = new Map<string, number>();
147
+ for (const [docId, v] of entries) {
148
+ updatedAtMap.set(docId, v?.updatedAt ?? v?.createdAt ?? 0);
149
+ }
150
+
120
151
  // Pre-cache cover images immediately (fire-and-forget)
121
152
  this._prefetchCovers(entries).catch(() => null);
122
153
 
@@ -124,11 +155,16 @@ export class BackgroundSyncManager extends EventEmitter {
124
155
  const queue = this._buildQueue(entries);
125
156
 
126
157
  // Sync all docs respecting concurrency limit
127
- await Promise.all(queue.map((docId) => this._syncWithSemaphore(docId)));
158
+ await Promise.all(
159
+ queue.map((docId) =>
160
+ this._syncWithSemaphore(docId, updatedAtMap.get(docId) ?? 0),
161
+ ),
162
+ );
128
163
  }
129
164
 
130
165
  /** Sync a single document by ID. */
131
166
  async syncDoc(docId: string): Promise<DocSyncState> {
167
+ await this.init();
132
168
  const state = await this._doSyncDoc(docId);
133
169
  this.syncStates.set(docId, state);
134
170
  await this.persistence.setState(state).catch(() => null);
@@ -194,8 +230,24 @@ export class BackgroundSyncManager extends EventEmitter {
194
230
  return items.map((i) => i.docId);
195
231
  }
196
232
 
197
- private async _syncWithSemaphore(docId: string): Promise<void> {
233
+ private async _syncWithSemaphore(
234
+ docId: string,
235
+ updatedAt: number,
236
+ ): Promise<void> {
198
237
  if (this._destroyed) return;
238
+
239
+ // Skip if already synced and doc hasn't changed since last sync
240
+ const existing = this.syncStates.get(docId);
241
+ if (
242
+ existing &&
243
+ existing.status === "synced" &&
244
+ existing.lastSynced !== null &&
245
+ existing.lastSynced >= updatedAt
246
+ ) {
247
+ this.emit("stateChanged", { docId, state: existing });
248
+ return;
249
+ }
250
+
199
251
  await this.semaphore.acquire();
200
252
  try {
201
253
  const state = await this._doSyncDoc(docId);
@@ -218,9 +270,9 @@ export class BackgroundSyncManager extends EventEmitter {
218
270
  this.syncStates.set(docId, syncing);
219
271
  this.emit("stateChanged", { docId, state: syncing });
220
272
 
273
+ let isE2E = false;
221
274
  try {
222
275
  // Check encryption type
223
- let isE2E = false;
224
276
  try {
225
277
  const enc = await this.client.getDocEncryption(docId);
226
278
  isE2E = enc.mode === "e2e";
@@ -240,7 +292,7 @@ export class BackgroundSyncManager extends EventEmitter {
240
292
  status: "error",
241
293
  lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
242
294
  error,
243
- isE2E: false,
295
+ isE2E,
244
296
  };
245
297
  }
246
298
  }
@@ -18,7 +18,8 @@ function fromBase64(b64: string): Uint8Array {
18
18
  }
19
19
 
20
20
  export class DocKeyManager {
21
- private cache = new Map<string, { key: CryptoKey; epoch: number }>();
21
+ private cache = new Map<string, { key: CryptoKey; epoch: number; fetchedAt: number }>();
22
+ private static readonly CACHE_TTL = 10 * 60 * 1000; // 10 minutes
22
23
 
23
24
  /** Generate a new random AES-256-GCM document key. */
24
25
  static async generateDocKey(): Promise<CryptoKey> {
@@ -39,7 +40,9 @@ export class DocKeyManager {
39
40
  keystore: CryptoIdentityKeystore,
40
41
  ): Promise<CryptoKey | null> {
41
42
  const cached = this.cache.get(docId);
42
- if (cached) return cached.key;
43
+ if (cached && Date.now() - cached.fetchedAt < DocKeyManager.CACHE_TTL) {
44
+ return cached.key;
45
+ }
43
46
 
44
47
  const envelope = await client.getMyKeyEnvelope(docId);
45
48
  if (!envelope) return null;
@@ -48,7 +51,7 @@ export class DocKeyManager {
48
51
  try {
49
52
  const wrapped = fromBase64(envelope.encrypted_key);
50
53
  const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
51
- this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch });
54
+ this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch, fetchedAt: Date.now() });
52
55
  return docKey;
53
56
  } finally {
54
57
  x25519PrivKey.fill(0);
@@ -13,11 +13,26 @@ export default class EventEmitter {
13
13
  return this;
14
14
  }
15
15
 
16
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
17
+ public once(event: string, fn: Function): this {
18
+ const wrapper = (...args: any[]) => {
19
+ this.off(event, wrapper);
20
+ fn.apply(this, args);
21
+ };
22
+ return this.on(event, wrapper);
23
+ }
24
+
16
25
  protected emit(event: string, ...args: any): this {
17
26
  const callbacks = this.callbacks[event];
18
27
 
19
28
  if (callbacks) {
20
- callbacks.forEach((callback) => callback.apply(this, args));
29
+ for (const callback of callbacks) {
30
+ try {
31
+ callback.apply(this, args);
32
+ } catch (err) {
33
+ console.error(`[EventEmitter] Error in "${event}" listener:`, err);
34
+ }
35
+ }
21
36
  }
22
37
 
23
38
  return this;
@@ -75,7 +75,7 @@ export class FileBlobStore extends EventEmitter {
75
75
  private static readonly NOT_FOUND_TTL = 5 * 60 * 1000;
76
76
 
77
77
  /** Prevents concurrent flush runs. */
78
- private _flushing = false;
78
+ private _flushPromise: Promise<void> | null = null;
79
79
 
80
80
  private readonly _onlineHandler: () => void;
81
81
 
@@ -231,6 +231,62 @@ export class FileBlobStore extends EventEmitter {
231
231
  return entry?.blob ?? null;
232
232
  }
233
233
 
234
+ /** Return metadata for all cached blobs (for storage stats). */
235
+ async getAllCachedEntries(): Promise<Array<{
236
+ docId: string; uploadId: string; filename: string;
237
+ mimeType: string; size: number; cachedAt: number;
238
+ }>> {
239
+ const db = await this.getDb();
240
+ if (!db) return [];
241
+ return new Promise((resolve, reject) => {
242
+ const tx = db.transaction("blobs", "readonly");
243
+ const store = tx.objectStore("blobs");
244
+ const keysReq = store.getAllKeys();
245
+ const valuesReq = store.getAll();
246
+ tx.oncomplete = () => {
247
+ const keys = keysReq.result as string[];
248
+ const values = valuesReq.result as BlobCacheEntry[];
249
+ resolve(keys.map((key, i) => {
250
+ const slashIdx = key.indexOf("/");
251
+ const docId = key.slice(0, slashIdx);
252
+ const uploadId = key.slice(slashIdx + 1);
253
+ const e = values[i]!;
254
+ return { docId, uploadId, filename: e.filename, mimeType: e.mime_type, size: e.blob.size, cachedAt: e.cachedAt };
255
+ }));
256
+ };
257
+ tx.onerror = () => reject(tx.error);
258
+ });
259
+ }
260
+
261
+ /** Revoke all object URLs and clear the entire blob cache from IDB. */
262
+ async clearAllBlobs(): Promise<void> {
263
+ for (const url of this.objectUrls.values()) URL.revokeObjectURL(url);
264
+ this.objectUrls.clear();
265
+ const db = await this.getDb();
266
+ if (!db) return;
267
+ return new Promise((resolve, reject) => {
268
+ const tx = db.transaction("blobs", "readwrite");
269
+ const req = tx.objectStore("blobs").clear();
270
+ req.onsuccess = () => resolve();
271
+ req.onerror = () => reject(req.error);
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Revoke the in-memory object URL without touching the IDB cache.
277
+ * The next call to getBlobUrl() will re-create a fresh URL from IDB.
278
+ * Use this when an <img> @error fires — the blob data is fine, only
279
+ * the object URL reference is stale.
280
+ */
281
+ invalidateUrl(docId: string, uploadId: string): void {
282
+ const key = this.blobKey(docId, uploadId);
283
+ const url = this.objectUrls.get(key);
284
+ if (url) {
285
+ URL.revokeObjectURL(url);
286
+ this.objectUrls.delete(key);
287
+ }
288
+ }
289
+
234
290
  /** Revoke the object URL and remove the blob from cache. */
235
291
  async evictBlob(docId: string, uploadId: string): Promise<void> {
236
292
  const key = this.blobKey(docId, uploadId);
@@ -303,29 +359,33 @@ export class FileBlobStore extends EventEmitter {
303
359
  * Entries that fail are marked with status "error" and left in the queue.
304
360
  */
305
361
  async flushQueue(): Promise<void> {
306
- if (this._flushing || !this.client) return;
307
- this._flushing = true;
308
-
362
+ if (this._flushPromise || !this.client) return;
363
+ this._flushPromise = this._doFlush();
309
364
  try {
310
- const all = await this.getQueue();
311
- const pending = all.filter((e) => e.status === "pending");
312
-
313
- for (const entry of pending) {
314
- await this._updateQueueEntry(entry.id, { status: "uploading" });
315
- this.emit("upload:started", { ...entry, status: "uploading" });
316
-
317
- try {
318
- await this.client.upload(entry.docId, entry.file, entry.filename);
319
- await this._updateQueueEntry(entry.id, { status: "done" });
320
- this.emit("upload:done", { ...entry, status: "done" });
321
- } catch (err) {
322
- const message = err instanceof Error ? err.message : String(err);
323
- await this._updateQueueEntry(entry.id, { status: "error", error: message });
324
- this.emit("upload:error", { ...entry, status: "error", error: message });
325
- }
326
- }
365
+ await this._flushPromise;
327
366
  } finally {
328
- this._flushing = false;
367
+ this._flushPromise = null;
368
+ }
369
+ }
370
+
371
+ private async _doFlush(): Promise<void> {
372
+ if (!this.client) return;
373
+ const all = await this.getQueue();
374
+ const pending = all.filter((e) => e.status === "pending");
375
+
376
+ for (const entry of pending) {
377
+ await this._updateQueueEntry(entry.id, { status: "uploading" });
378
+ this.emit("upload:started", { ...entry, status: "uploading" });
379
+
380
+ try {
381
+ await this.client.upload(entry.docId, entry.file, entry.filename);
382
+ await this._updateQueueEntry(entry.id, { status: "done" });
383
+ this.emit("upload:done", { ...entry, status: "done" });
384
+ } catch (err) {
385
+ const message = err instanceof Error ? err.message : String(err);
386
+ await this._updateQueueEntry(entry.id, { status: "error", error: message });
387
+ this.emit("upload:error", { ...entry, status: "error", error: message });
388
+ }
329
389
  }
330
390
  }
331
391