@rgby/collab-core 1.0.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,337 @@
1
+ /**
2
+ * Upload Queue Manager
3
+ * Handles offline file uploads with IndexedDB queue
4
+ */
5
+
6
+ export interface PendingUpload {
7
+ id: string;
8
+ spaceId: string;
9
+ documentId: string | null;
10
+ file: Blob;
11
+ filename: string;
12
+ mimeType: string;
13
+ timestamp: number;
14
+ retryCount: number;
15
+ tempUrl: string; // blob: URL for immediate display
16
+ }
17
+
18
+ export interface UploadQueueOptions {
19
+ dbName?: string;
20
+ storeName?: string;
21
+ maxRetries?: number;
22
+ retryDelay?: number; // Base delay in ms, exponential backoff
23
+ }
24
+
25
+ export type UploadQueueEventType = 'queue-changed' | 'upload-complete' | 'upload-failed';
26
+
27
+ export interface UploadQueueEvent {
28
+ type: UploadQueueEventType;
29
+ upload?: PendingUpload;
30
+ error?: Error;
31
+ }
32
+
33
+ export class UploadQueueManager {
34
+ private db: IDBDatabase | null = null;
35
+ private dbName: string;
36
+ private storeName: string;
37
+ private maxRetries: number;
38
+ private retryDelay: number;
39
+ private listeners: Map<UploadQueueEventType, Set<(event: UploadQueueEvent) => void>> = new Map();
40
+ private isProcessing: boolean = false;
41
+
42
+ constructor(options: UploadQueueOptions = {}) {
43
+ this.dbName = options.dbName || 'collab-upload-queue';
44
+ this.storeName = options.storeName || 'pending_uploads';
45
+ this.maxRetries = options.maxRetries || 3;
46
+ this.retryDelay = options.retryDelay || 1000;
47
+
48
+ // Listen for online/offline events
49
+ window.addEventListener('online', () => {
50
+ console.log('[UploadQueue] Network online, processing queue');
51
+ this.processQueue();
52
+ });
53
+
54
+ window.addEventListener('offline', () => {
55
+ console.log('[UploadQueue] Network offline');
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Initialize the IndexedDB database
61
+ */
62
+ async init(): Promise<void> {
63
+ if (this.db) return;
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const request = indexedDB.open(this.dbName, 1);
67
+
68
+ request.onerror = () => {
69
+ reject(new Error('Failed to open IndexedDB'));
70
+ };
71
+
72
+ request.onsuccess = () => {
73
+ this.db = request.result;
74
+ console.log('[UploadQueue] IndexedDB initialized');
75
+ resolve();
76
+ };
77
+
78
+ request.onupgradeneeded = (event) => {
79
+ const db = (event.target as IDBOpenDBRequest).result;
80
+ if (!db.objectStoreNames.contains(this.storeName)) {
81
+ const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
82
+ store.createIndex('timestamp', 'timestamp', { unique: false });
83
+ store.createIndex('spaceId', 'spaceId', { unique: false });
84
+ console.log('[UploadQueue] Object store created');
85
+ }
86
+ };
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Add an upload to the queue
92
+ */
93
+ async add(upload: Omit<PendingUpload, 'id' | 'timestamp' | 'retryCount' | 'tempUrl'>): Promise<PendingUpload> {
94
+ await this.init();
95
+ if (!this.db) throw new Error('Database not initialized');
96
+
97
+ const id = `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
98
+ const tempUrl = URL.createObjectURL(upload.file);
99
+
100
+ const pendingUpload: PendingUpload = {
101
+ id,
102
+ spaceId: upload.spaceId,
103
+ documentId: upload.documentId,
104
+ file: upload.file,
105
+ filename: upload.filename,
106
+ mimeType: upload.mimeType,
107
+ timestamp: Date.now(),
108
+ retryCount: 0,
109
+ tempUrl
110
+ };
111
+
112
+ return new Promise((resolve, reject) => {
113
+ const transaction = this.db!.transaction([this.storeName], 'readwrite');
114
+ const store = transaction.objectStore(this.storeName);
115
+ const request = store.add(pendingUpload);
116
+
117
+ request.onsuccess = () => {
118
+ console.log('[UploadQueue] Upload added to queue:', id);
119
+ this.emit('queue-changed', { type: 'queue-changed', upload: pendingUpload });
120
+ resolve(pendingUpload);
121
+ };
122
+
123
+ request.onerror = () => {
124
+ reject(new Error('Failed to add upload to queue'));
125
+ };
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Get all pending uploads
131
+ */
132
+ async getAll(): Promise<PendingUpload[]> {
133
+ await this.init();
134
+ if (!this.db) return [];
135
+
136
+ return new Promise((resolve, reject) => {
137
+ const transaction = this.db!.transaction([this.storeName], 'readonly');
138
+ const store = transaction.objectStore(this.storeName);
139
+ const request = store.getAll();
140
+
141
+ request.onsuccess = () => {
142
+ resolve(request.result || []);
143
+ };
144
+
145
+ request.onerror = () => {
146
+ reject(new Error('Failed to get uploads from queue'));
147
+ };
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Remove an upload from the queue
153
+ */
154
+ async remove(id: string): Promise<void> {
155
+ await this.init();
156
+ if (!this.db) return;
157
+
158
+ return new Promise((resolve, reject) => {
159
+ const transaction = this.db!.transaction([this.storeName], 'readwrite');
160
+ const store = transaction.objectStore(this.storeName);
161
+ const request = store.delete(id);
162
+
163
+ request.onsuccess = () => {
164
+ console.log('[UploadQueue] Upload removed from queue:', id);
165
+ this.emit('queue-changed', { type: 'queue-changed' });
166
+ resolve();
167
+ };
168
+
169
+ request.onerror = () => {
170
+ reject(new Error('Failed to remove upload from queue'));
171
+ };
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Update an upload in the queue
177
+ */
178
+ async update(upload: PendingUpload): Promise<void> {
179
+ await this.init();
180
+ if (!this.db) return;
181
+
182
+ return new Promise((resolve, reject) => {
183
+ const transaction = this.db!.transaction([this.storeName], 'readwrite');
184
+ const store = transaction.objectStore(this.storeName);
185
+ const request = store.put(upload);
186
+
187
+ request.onsuccess = () => {
188
+ resolve();
189
+ };
190
+
191
+ request.onerror = () => {
192
+ reject(new Error('Failed to update upload in queue'));
193
+ };
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Clear all uploads from the queue
199
+ */
200
+ async clear(): Promise<void> {
201
+ await this.init();
202
+ if (!this.db) return;
203
+
204
+ // Revoke all blob URLs first
205
+ const uploads = await this.getAll();
206
+ uploads.forEach(upload => {
207
+ if (upload.tempUrl.startsWith('blob:')) {
208
+ URL.revokeObjectURL(upload.tempUrl);
209
+ }
210
+ });
211
+
212
+ return new Promise((resolve, reject) => {
213
+ const transaction = this.db!.transaction([this.storeName], 'readwrite');
214
+ const store = transaction.objectStore(this.storeName);
215
+ const request = store.clear();
216
+
217
+ request.onsuccess = () => {
218
+ console.log('[UploadQueue] Queue cleared');
219
+ this.emit('queue-changed', { type: 'queue-changed' });
220
+ resolve();
221
+ };
222
+
223
+ request.onerror = () => {
224
+ reject(new Error('Failed to clear queue'));
225
+ };
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Process the queue - upload pending files
231
+ */
232
+ async processQueue(uploadFn?: (upload: PendingUpload) => Promise<any>): Promise<void> {
233
+ if (this.isProcessing || !navigator.onLine) {
234
+ console.log('[UploadQueue] Skipping queue processing (processing:', this.isProcessing, 'online:', navigator.onLine, ')');
235
+ return;
236
+ }
237
+
238
+ this.isProcessing = true;
239
+ console.log('[UploadQueue] Processing queue...');
240
+
241
+ try {
242
+ const uploads = await this.getAll();
243
+ console.log('[UploadQueue] Found', uploads.length, 'pending uploads');
244
+
245
+ for (const upload of uploads) {
246
+ try {
247
+ if (!uploadFn) {
248
+ console.warn('[UploadQueue] No upload function provided, skipping upload:', upload.id);
249
+ continue;
250
+ }
251
+
252
+ console.log('[UploadQueue] Processing upload:', upload.id);
253
+ const result = await uploadFn(upload);
254
+
255
+ // Success - remove from queue and revoke blob URL
256
+ await this.remove(upload.id);
257
+ if (upload.tempUrl.startsWith('blob:')) {
258
+ URL.revokeObjectURL(upload.tempUrl);
259
+ }
260
+
261
+ this.emit('upload-complete', { type: 'upload-complete', upload });
262
+ console.log('[UploadQueue] Upload complete:', upload.id);
263
+ } catch (error) {
264
+ console.error('[UploadQueue] Upload failed:', upload.id, error);
265
+
266
+ // Increment retry count
267
+ upload.retryCount += 1;
268
+
269
+ if (upload.retryCount >= this.maxRetries) {
270
+ // Max retries reached - remove from queue
271
+ console.error('[UploadQueue] Max retries reached, removing:', upload.id);
272
+ await this.remove(upload.id);
273
+ if (upload.tempUrl.startsWith('blob:')) {
274
+ URL.revokeObjectURL(upload.tempUrl);
275
+ }
276
+ this.emit('upload-failed', { type: 'upload-failed', upload, error: error as Error });
277
+ } else {
278
+ // Update retry count and schedule retry
279
+ await this.update(upload);
280
+ console.log('[UploadQueue] Retry scheduled for:', upload.id, 'attempt:', upload.retryCount);
281
+
282
+ // Exponential backoff
283
+ const delay = this.retryDelay * Math.pow(2, upload.retryCount - 1);
284
+ await new Promise(resolve => setTimeout(resolve, delay));
285
+ }
286
+ }
287
+ }
288
+ } finally {
289
+ this.isProcessing = false;
290
+ console.log('[UploadQueue] Queue processing complete');
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Get the number of pending uploads
296
+ */
297
+ async count(): Promise<number> {
298
+ const uploads = await this.getAll();
299
+ return uploads.length;
300
+ }
301
+
302
+ /**
303
+ * Add event listener
304
+ */
305
+ on(event: UploadQueueEventType, callback: (event: UploadQueueEvent) => void): () => void {
306
+ if (!this.listeners.has(event)) {
307
+ this.listeners.set(event, new Set());
308
+ }
309
+ this.listeners.get(event)!.add(callback);
310
+
311
+ // Return unsubscribe function
312
+ return () => {
313
+ this.listeners.get(event)?.delete(callback);
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Emit event
319
+ */
320
+ private emit(event: UploadQueueEventType, data: UploadQueueEvent): void {
321
+ const callbacks = this.listeners.get(event);
322
+ if (callbacks) {
323
+ callbacks.forEach(callback => callback(data));
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Close the database connection
329
+ */
330
+ async close(): Promise<void> {
331
+ if (this.db) {
332
+ this.db.close();
333
+ this.db = null;
334
+ console.log('[UploadQueue] Database closed');
335
+ }
336
+ }
337
+ }