@roeehrl/tinode-sdk 0.25.1-sqlite.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.
package/src/db.js ADDED
@@ -0,0 +1,1021 @@
1
+ /**
2
+ * @file Helper methods for dealing with IndexedDB cache of messages, users, and topics.
3
+ *
4
+ * @copyright 2015-2025 Tinode LLC.
5
+ */
6
+ 'use strict';
7
+
8
+ // NOTE TO DEVELOPERS:
9
+ // Localizable strings should be double quoted "строка на другом языке",
10
+ // non-localizable strings should be single quoted 'non-localized'.
11
+
12
+ const DB_VERSION = 3;
13
+ const DB_NAME = 'tinode-web';
14
+
15
+ let IDBProvider;
16
+
17
+ // Custom storage provider (e.g., SQLiteStorage for React Native)
18
+ let _storageProvider = null;
19
+
20
+ export default class DB {
21
+ #onError = _ => {};
22
+ #logger = _ => {};
23
+
24
+ // Instance of IndexDB.
25
+ db = null;
26
+ // Indicator that the cache is disabled.
27
+ disabled = true;
28
+ // Reference to custom storage provider (if using delegation)
29
+ #delegateStorage = null;
30
+
31
+ constructor(onError, logger) {
32
+ this.#onError = onError || this.#onError;
33
+ this.#logger = logger || this.#logger;
34
+
35
+ // If a custom storage provider is set, use it instead of IndexedDB
36
+ if (_storageProvider) {
37
+ this.#delegateStorage = _storageProvider;
38
+ this.disabled = false;
39
+ }
40
+ }
41
+
42
+ // Helper to check if we should delegate to custom storage
43
+ #shouldDelegate() {
44
+ return this.#delegateStorage !== null;
45
+ }
46
+
47
+ #mapObjects(source, callback, context) {
48
+ if (!this.db) {
49
+ return disabled ?
50
+ Promise.resolve([]) :
51
+ Promise.reject(new Error("not initialized"));
52
+ }
53
+
54
+ return new Promise((resolve, reject) => {
55
+ const trx = this.db.transaction([source]);
56
+ trx.onerror = event => {
57
+ this.#logger('PCache', 'mapObjects', source, event.target.error);
58
+ reject(event.target.error);
59
+ };
60
+ trx.objectStore(source).getAll().onsuccess = event => {
61
+ if (callback) {
62
+ event.target.result.forEach(topic => {
63
+ callback.call(context, topic);
64
+ });
65
+ }
66
+ resolve(event.target.result);
67
+ };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Initialize persistent cache: open or create/upgrade if needed.
73
+ * @returns {Promise} promise to be resolved/rejected when the DB is initialized.
74
+ */
75
+ initDatabase() {
76
+ console.log('[DB] initDatabase CALLED, shouldDelegate:', this.#shouldDelegate(), 'delegateStorage:', !!this.#delegateStorage);
77
+ // Delegate to custom storage if set
78
+ if (this.#shouldDelegate()) {
79
+ console.log('[DB] initDatabase DELEGATING to SQLiteStorage');
80
+ return this.#delegateStorage.initDatabase().then(result => {
81
+ console.log('[DB] initDatabase: SQLiteStorage initialized successfully');
82
+ return result;
83
+ });
84
+ }
85
+ console.log('[DB] initDatabase using IndexedDB');
86
+
87
+ return new Promise((resolve, reject) => {
88
+ // Open the database and initialize callbacks.
89
+ const req = IDBProvider.open(DB_NAME, DB_VERSION);
90
+ req.onsuccess = event => {
91
+ this.db = event.target.result;
92
+ this.disabled = false;
93
+
94
+ // This handler is called when a different tab tries to upgrade the database.
95
+ this.db.onversionchange = _ => {
96
+ this.#logger('PCache', "another tab tries to upgrade DB, shutting down");
97
+ this.db.close();
98
+ this.db = null;
99
+ this.disabled = true;
100
+ };
101
+
102
+ resolve(this.db);
103
+ };
104
+ req.onerror = event => {
105
+ this.#logger('PCache', "failed to initialize", event);
106
+ reject(event.target.error);
107
+ this.#onError(event.target.error);
108
+ };
109
+ req.onupgradeneeded = event => {
110
+ this.db = event.target.result;
111
+
112
+ this.db.onerror = event => {
113
+ this.#logger('PCache', "failed to create storage", event);
114
+ this.#onError(event.target.error);
115
+ };
116
+
117
+ // Individual object stores.
118
+
119
+ // Alternatively could use event.oldVersion and event.newVersion
120
+ // to determine which object stores to create or upgrade.
121
+
122
+ if (!this.db.objectStoreNames.contains('topic')) {
123
+ // Object store (table) for topics. The primary key is the topic name.
124
+ this.db.createObjectStore('topic', {
125
+ keyPath: 'name'
126
+ });
127
+ }
128
+
129
+ if (!this.db.objectStoreNames.contains('user')) {
130
+ // Users object store. UID is the primary key.
131
+ this.db.createObjectStore('user', {
132
+ keyPath: 'uid'
133
+ });
134
+ }
135
+
136
+ if (!this.db.objectStoreNames.contains('subscription')) {
137
+ // Subscriptions object store topic <-> user. Topic name + UID is the primary key.
138
+ this.db.createObjectStore('subscription', {
139
+ keyPath: ['topic', 'uid']
140
+ });
141
+ }
142
+
143
+ if (!this.db.objectStoreNames.contains('message')) {
144
+ // Messages object store. The primary key is topic name + seq.
145
+ this.db.createObjectStore('message', {
146
+ keyPath: ['topic', 'seq']
147
+ });
148
+ }
149
+
150
+ if (!this.db.objectStoreNames.contains('dellog')) {
151
+ // Records of deleted message ranges. The primary key is topic name + low seq.
152
+ const dellog = this.db.createObjectStore('dellog', {
153
+ keyPath: ['topic', 'low', 'hi']
154
+ });
155
+ if (!dellog.indexNames.contains('topic_clear')) {
156
+ dellog.createIndex('topic_clear', ['topic', 'clear'], {
157
+ unique: false
158
+ });
159
+ }
160
+ }
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Delete persistent cache.
167
+ */
168
+ deleteDatabase() {
169
+ // Delegate to custom storage if set
170
+ if (this.#shouldDelegate()) {
171
+ return this.#delegateStorage.deleteDatabase();
172
+ }
173
+
174
+ // Close connection, otherwise operations will fail with 'onblocked'.
175
+ if (this.db) {
176
+ this.db.close();
177
+ this.db = null;
178
+ }
179
+ return new Promise((resolve, reject) => {
180
+ const req = IDBProvider.deleteDatabase(DB_NAME);
181
+ req.onblocked = _ => {
182
+ if (this.db) {
183
+ this.db.close();
184
+ }
185
+ const err = new Error("blocked");
186
+ this.#logger('PCache', 'deleteDatabase', err);
187
+ reject(err);
188
+ };
189
+ req.onsuccess = _ => {
190
+ this.db = null;
191
+ this.disabled = true;
192
+ resolve(true);
193
+ };
194
+ req.onerror = event => {
195
+ this.#logger('PCache', 'deleteDatabase', event.target.error);
196
+ reject(event.target.error);
197
+ };
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Check if persistent cache is ready for use.
203
+ * @memberOf DB
204
+ * @returns {boolean} <code>true</code> if cache is ready, <code>false</code> otherwise.
205
+ */
206
+ isReady() {
207
+ // Delegate to custom storage if set
208
+ if (this.#shouldDelegate()) {
209
+ return this.#delegateStorage.isReady();
210
+ }
211
+ return !!this.db;
212
+ }
213
+
214
+ // Topics.
215
+
216
+ /**
217
+ * Save to cache or update topic in persistent cache.
218
+ * @memberOf DB
219
+ * @param {Topic} topic - topic to be added or updated.
220
+ * @returns {Promise} promise resolved/rejected on operation completion.
221
+ */
222
+ updTopic(topic) {
223
+ console.log('[DB] updTopic CALLED:', topic?.name, 'shouldDelegate:', this.#shouldDelegate());
224
+ // Delegate to custom storage if set
225
+ if (this.#shouldDelegate()) {
226
+ return this.#delegateStorage.updTopic(topic);
227
+ }
228
+ if (!this.isReady()) {
229
+ return this.disabled ?
230
+ Promise.resolve() :
231
+ Promise.reject(new Error("not initialized"));
232
+ }
233
+ return new Promise((resolve, reject) => {
234
+ const trx = this.db.transaction(['topic'], 'readwrite');
235
+ trx.oncomplete = event => {
236
+ resolve(event.target.result);
237
+ };
238
+ trx.onerror = event => {
239
+ this.#logger('PCache', 'updTopic', event.target.error);
240
+ reject(event.target.error);
241
+ };
242
+ const req = trx.objectStore('topic').get(topic.name);
243
+ req.onsuccess = _ => {
244
+ trx.objectStore('topic').put(DB.#serializeTopic(req.result, topic));
245
+ trx.commit();
246
+ };
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Mark or unmark topic as deleted.
252
+ * @memberOf DB
253
+ * @param {string} name - name of the topic to mark or unmark.
254
+ * @param {boolean} deleted - status
255
+ * @return {Promise} promise resolved/rejected on operation completion.
256
+ */
257
+ markTopicAsDeleted(name, deleted) {
258
+ // Delegate to custom storage if set
259
+ if (this.#shouldDelegate()) {
260
+ return this.#delegateStorage.markTopicAsDeleted(name, deleted);
261
+ }
262
+ if (!this.isReady()) {
263
+ return this.disabled ?
264
+ Promise.resolve() :
265
+ Promise.reject(new Error("not initialized"));
266
+ }
267
+ return new Promise((resolve, reject) => {
268
+ const trx = this.db.transaction(['topic'], 'readwrite');
269
+ trx.oncomplete = event => {
270
+ resolve(event.target.result);
271
+ };
272
+ trx.onerror = event => {
273
+ this.#logger('PCache', 'markTopicAsDeleted', event.target.error);
274
+ reject(event.target.error);
275
+ };
276
+ const req = trx.objectStore('topic').get(name);
277
+ req.onsuccess = event => {
278
+ const topic = event.target.result;
279
+ if (topic && topic._deleted != deleted) {
280
+ topic._deleted = deleted;
281
+ trx.objectStore('topic').put(topic);
282
+ }
283
+ trx.commit();
284
+ };
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Remove topic from persistent cache.
290
+ * @memberOf DB
291
+ * @param {string} name - name of the topic to remove from database.
292
+ * @return {Promise} promise resolved/rejected on operation completion.
293
+ */
294
+ remTopic(name) {
295
+ // Delegate to custom storage if set
296
+ if (this.#shouldDelegate()) {
297
+ return this.#delegateStorage.remTopic(name);
298
+ }
299
+ if (!this.isReady()) {
300
+ return this.disabled ?
301
+ Promise.resolve() :
302
+ Promise.reject(new Error("not initialized"));
303
+ }
304
+ return new Promise((resolve, reject) => {
305
+ const trx = this.db.transaction(['topic', 'subscription', 'message'], 'readwrite');
306
+ trx.oncomplete = event => {
307
+ resolve(event.target.result);
308
+ };
309
+ trx.onerror = event => {
310
+ this.#logger('PCache', 'remTopic', event.target.error);
311
+ reject(event.target.error);
312
+ };
313
+ trx.objectStore('topic').delete(IDBKeyRange.only(name));
314
+ trx.objectStore('subscription').delete(IDBKeyRange.bound([name, '-'], [name, '~']));
315
+ trx.objectStore('message').delete(IDBKeyRange.bound([name, 0], [name, Number.MAX_SAFE_INTEGER]));
316
+ trx.commit();
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Execute a callback for each stored topic.
322
+ * @memberOf DB
323
+ * @param {function} callback - function to call for each topic.
324
+ * @param {Object} context - the value or <code>this</code> inside the callback.
325
+ * @return {Promise} promise resolved/rejected on operation completion.
326
+ */
327
+ mapTopics(callback, context) {
328
+ console.log('[DB] mapTopics CALLED, shouldDelegate:', this.#shouldDelegate());
329
+ // Delegate to custom storage if set
330
+ if (this.#shouldDelegate()) {
331
+ console.log('[DB] mapTopics DELEGATING to SQLiteStorage');
332
+ return this.#delegateStorage.mapTopics(callback, context).then(result => {
333
+ console.log('[DB] mapTopics: SQLiteStorage returned', result ? result.length : 0, 'topics');
334
+ return result;
335
+ });
336
+ }
337
+ console.log('[DB] mapTopics using IndexedDB');
338
+ return this.#mapObjects('topic', callback, context);
339
+ }
340
+
341
+ /**
342
+ * Copy data from serialized object to topic.
343
+ * @memberOf DB
344
+ * @param {Topic} topic - target to deserialize to.
345
+ * @param {Object} src - serialized data to copy from.
346
+ */
347
+ deserializeTopic(topic, src) {
348
+ // Delegate to custom storage if set
349
+ if (this.#shouldDelegate()) {
350
+ return this.#delegateStorage.deserializeTopic(topic, src);
351
+ }
352
+ DB.#deserializeTopic(topic, src);
353
+ }
354
+
355
+ // Users.
356
+ /**
357
+ * Add or update user object in the persistent cache.
358
+ * @memberOf DB
359
+ * @param {string} uid - ID of the user to save or update.
360
+ * @param {Object} pub - user's <code>public</code> information.
361
+ * @returns {Promise} promise resolved/rejected on operation completion.
362
+ */
363
+ updUser(uid, pub) {
364
+ // Delegate to custom storage if set
365
+ if (this.#shouldDelegate()) {
366
+ return this.#delegateStorage.updUser(uid, pub);
367
+ }
368
+ if (arguments.length < 2 || pub === undefined) {
369
+ // No point inupdating user with invalid data.
370
+ return;
371
+ }
372
+ if (!this.isReady()) {
373
+ return this.disabled ?
374
+ Promise.resolve() :
375
+ Promise.reject(new Error("not initialized"));
376
+ }
377
+ return new Promise((resolve, reject) => {
378
+ const trx = this.db.transaction(['user'], 'readwrite');
379
+ trx.oncomplete = event => {
380
+ resolve(event.target.result);
381
+ };
382
+ trx.onerror = event => {
383
+ this.#logger('PCache', 'updUser', event.target.error);
384
+ reject(event.target.error);
385
+ };
386
+ trx.objectStore('user').put({
387
+ uid: uid,
388
+ public: pub
389
+ });
390
+ trx.commit();
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Remove user from persistent cache.
396
+ * @memberOf DB
397
+ * @param {string} uid - ID of the user to remove from the cache.
398
+ * @return {Promise} promise resolved/rejected on operation completion.
399
+ */
400
+ remUser(uid) {
401
+ // Delegate to custom storage if set
402
+ if (this.#shouldDelegate()) {
403
+ return this.#delegateStorage.remUser(uid);
404
+ }
405
+ if (!this.isReady()) {
406
+ return this.disabled ?
407
+ Promise.resolve() :
408
+ Promise.reject(new Error("not initialized"));
409
+ }
410
+ return new Promise((resolve, reject) => {
411
+ const trx = this.db.transaction(['user'], 'readwrite');
412
+ trx.oncomplete = event => {
413
+ resolve(event.target.result);
414
+ };
415
+ trx.onerror = event => {
416
+ this.#logger('PCache', 'remUser', event.target.error);
417
+ reject(event.target.error);
418
+ };
419
+ trx.objectStore('user').delete(IDBKeyRange.only(uid));
420
+ trx.commit();
421
+ });
422
+ }
423
+
424
+ /**
425
+ * Execute a callback for each stored user.
426
+ * @memberOf DB
427
+ * @param {function} callback - function to call for each topic.
428
+ * @param {Object} context - the value or <code>this</code> inside the callback.
429
+ * @return {Promise} promise resolved/rejected on operation completion.
430
+ */
431
+ mapUsers(callback, context) {
432
+ // Delegate to custom storage if set
433
+ if (this.#shouldDelegate()) {
434
+ return this.#delegateStorage.mapUsers(callback, context);
435
+ }
436
+ return this.#mapObjects('user', callback, context);
437
+ }
438
+
439
+ /**
440
+ * Read a single user from persistent cache.
441
+ * @memberOf DB
442
+ * @param {string} uid - ID of the user to fetch from cache.
443
+ * @return {Promise} promise resolved/rejected on operation completion.
444
+ */
445
+ getUser(uid) {
446
+ // Delegate to custom storage if set
447
+ if (this.#shouldDelegate()) {
448
+ return this.#delegateStorage.getUser(uid);
449
+ }
450
+ if (!this.isReady()) {
451
+ return this.disabled ?
452
+ Promise.resolve() :
453
+ Promise.reject(new Error("not initialized"));
454
+ }
455
+ return new Promise((resolve, reject) => {
456
+ const trx = this.db.transaction(['user']);
457
+ trx.oncomplete = event => {
458
+ const user = event.target.result;
459
+ resolve({
460
+ user: user.uid,
461
+ public: user.public
462
+ });
463
+ };
464
+ trx.onerror = event => {
465
+ this.#logger('PCache', 'getUser', event.target.error);
466
+ reject(event.target.error);
467
+ };
468
+ trx.objectStore('user').get(uid);
469
+ });
470
+ }
471
+
472
+ // Subscriptions.
473
+ /**
474
+ * Add or update subscription in persistent cache.
475
+ * @memberOf DB
476
+ * @param {string} topicName - name of the topic which owns the message.
477
+ * @param {string} uid - ID of the subscribed user.
478
+ * @param {Object} sub - subscription to save.
479
+ * @return {Promise} promise resolved/rejected on operation completion.
480
+ */
481
+ updSubscription(topicName, uid, sub) {
482
+ // Delegate to custom storage if set
483
+ if (this.#shouldDelegate()) {
484
+ return this.#delegateStorage.updSubscription(topicName, uid, sub);
485
+ }
486
+ if (!this.isReady()) {
487
+ return this.disabled ?
488
+ Promise.resolve() :
489
+ Promise.reject(new Error("not initialized"));
490
+ }
491
+ return new Promise((resolve, reject) => {
492
+ const trx = this.db.transaction(['subscription'], 'readwrite');
493
+ trx.oncomplete = event => {
494
+ resolve(event.target.result);
495
+ };
496
+ trx.onerror = event => {
497
+ this.#logger('PCache', 'updSubscription', event.target.error);
498
+ reject(event.target.error);
499
+ };
500
+ trx.objectStore('subscription').get([topicName, uid]).onsuccess = (event) => {
501
+ trx.objectStore('subscription').put(DB.#serializeSubscription(event.target.result, topicName, uid, sub));
502
+ trx.commit();
503
+ };
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Execute a callback for each cached subscription in a given topic.
509
+ * @memberOf DB
510
+ * @param {string} topicName - name of the topic which owns the subscriptions.
511
+ * @param {function} callback - function to call for each subscription.
512
+ * @param {Object} context - the value or <code>this</code> inside the callback.
513
+ * @return {Promise} promise resolved/rejected on operation completion.
514
+ */
515
+ mapSubscriptions(topicName, callback, context) {
516
+ // Delegate to custom storage if set
517
+ if (this.#shouldDelegate()) {
518
+ return this.#delegateStorage.mapSubscriptions(topicName, callback, context);
519
+ }
520
+ if (!this.isReady()) {
521
+ return this.disabled ?
522
+ Promise.resolve([]) :
523
+ Promise.reject(new Error("not initialized"));
524
+ }
525
+ return new Promise((resolve, reject) => {
526
+ const trx = this.db.transaction(['subscription']);
527
+ trx.onerror = (event) => {
528
+ this.#logger('PCache', 'mapSubscriptions', event.target.error);
529
+ reject(event.target.error);
530
+ };
531
+ trx.objectStore('subscription').getAll(IDBKeyRange.bound([topicName, '-'], [topicName, '~'])).onsuccess = (event) => {
532
+ if (callback) {
533
+ event.target.result.forEach((topic) => {
534
+ callback.call(context, topic);
535
+ });
536
+ }
537
+ resolve(event.target.result);
538
+ };
539
+ });
540
+ }
541
+
542
+ // Messages.
543
+
544
+ /**
545
+ * Save message to persistent cache.
546
+ * @memberOf DB
547
+ * @param {Object} msg - message to save.
548
+ * @return {Promise} promise resolved/rejected on operation completion.
549
+ */
550
+ addMessage(msg) {
551
+ // Delegate to custom storage if set
552
+ if (this.#shouldDelegate()) {
553
+ return this.#delegateStorage.addMessage(msg);
554
+ }
555
+ if (!this.isReady()) {
556
+ return this.disabled ?
557
+ Promise.resolve() :
558
+ Promise.reject(new Error("not initialized"));
559
+ }
560
+ return new Promise((resolve, reject) => {
561
+ const trx = this.db.transaction(['message'], 'readwrite');
562
+ trx.onsuccess = event => {
563
+ resolve(event.target.result);
564
+ };
565
+ trx.onerror = event => {
566
+ this.#logger('PCache', 'addMessage', event.target.error);
567
+ reject(event.target.error);
568
+ };
569
+ trx.objectStore('message').add(DB.#serializeMessage(null, msg));
570
+ trx.commit();
571
+ });
572
+ }
573
+
574
+ /**
575
+ * Update delivery status of a message stored in persistent cache.
576
+ * @memberOf DB
577
+ * @param {string} topicName - name of the topic which owns the message.
578
+ * @param {number} seq - ID of the message to update
579
+ * @param {number} status - new delivery status of the message.
580
+ * @return {Promise} promise resolved/rejected on operation completion.
581
+ */
582
+ updMessageStatus(topicName, seq, status) {
583
+ // Delegate to custom storage if set
584
+ if (this.#shouldDelegate()) {
585
+ return this.#delegateStorage.updMessageStatus(topicName, seq, status);
586
+ }
587
+ if (!this.isReady()) {
588
+ return this.disabled ?
589
+ Promise.resolve() :
590
+ Promise.reject(new Error("not initialized"));
591
+ }
592
+ return new Promise((resolve, reject) => {
593
+ const trx = this.db.transaction(['message'], 'readwrite');
594
+ trx.onsuccess = event => {
595
+ resolve(event.target.result);
596
+ };
597
+ trx.onerror = event => {
598
+ this.#logger('PCache', 'updMessageStatus', event.target.error);
599
+ reject(event.target.error);
600
+ };
601
+ const req = trx.objectStore('message').get(IDBKeyRange.only([topicName, seq]));
602
+ req.onsuccess = event => {
603
+ const src = req.result || event.target.result;
604
+ if (!src || src._status == status) {
605
+ trx.commit();
606
+ return;
607
+ }
608
+ trx.objectStore('message').put(DB.#serializeMessage(src, {
609
+ topic: topicName,
610
+ seq: seq,
611
+ _status: status
612
+ }));
613
+ trx.commit();
614
+ };
615
+ });
616
+ }
617
+
618
+ /**
619
+ * Remove one or more messages from persistent cache.
620
+ * @memberOf DB
621
+ * @param {string} topicName - name of the topic which owns the message.
622
+ * @param {number} from - id of the message to remove or lower boundary when removing range (inclusive).
623
+ * @param {number=} to - upper boundary (exclusive) when removing a range of messages.
624
+ * @return {Promise} promise resolved/rejected on operation completion.
625
+ */
626
+ remMessages(topicName, from, to) {
627
+ // Delegate to custom storage if set
628
+ if (this.#shouldDelegate()) {
629
+ return this.#delegateStorage.remMessages(topicName, from, to);
630
+ }
631
+ if (!this.isReady()) {
632
+ return this.disabled ?
633
+ Promise.resolve() :
634
+ Promise.reject(new Error("not initialized"));
635
+ }
636
+ return new Promise((resolve, reject) => {
637
+ if (!from && !to) {
638
+ from = 0;
639
+ to = Number.MAX_SAFE_INTEGER;
640
+ }
641
+ const range = to > 0 ? IDBKeyRange.bound([topicName, from], [topicName, to], false, true) :
642
+ IDBKeyRange.only([topicName, from]);
643
+ const trx = this.db.transaction(['message'], 'readwrite');
644
+ trx.onsuccess = event => {
645
+ resolve(event.target.result);
646
+ };
647
+ trx.onerror = event => {
648
+ this.#logger('PCache', 'remMessages', event.target.error);
649
+ reject(event.target.error);
650
+ };
651
+ trx.objectStore('message').delete(range);
652
+ trx.commit();
653
+ });
654
+ }
655
+
656
+ /**
657
+ * Retrieve messages from persistent store.
658
+ * @memberOf DB
659
+ * @param {string} topicName - name of the topic to retrieve messages from.
660
+ * @param {function} callback to call for each retrieved message.
661
+ * @param {GetDataType} query - parameters of the message range to retrieve.
662
+ *
663
+ * @return {Promise} promise resolved/rejected on operation completion.
664
+ */
665
+ readMessages(topicName, query, callback, context) {
666
+ console.log('[DB] readMessages CALLED:', topicName, 'shouldDelegate:', this.#shouldDelegate(), 'delegateStorage:', !!this.#delegateStorage);
667
+ // Delegate to custom storage if set
668
+ if (this.#shouldDelegate()) {
669
+ console.log('[DB] readMessages DELEGATING to SQLiteStorage');
670
+ return this.#delegateStorage.readMessages(topicName, query, callback, context);
671
+ }
672
+ console.log('[DB] readMessages NOT delegating, using IndexedDB');
673
+
674
+ query = query || {};
675
+
676
+ if (!this.isReady()) {
677
+ return this.disabled ?
678
+ Promise.resolve([]) :
679
+ Promise.reject(new Error("not initialized"));
680
+ }
681
+
682
+ const trx = this.db.transaction(['message']);
683
+ let result = [];
684
+
685
+ // Handle individual message ranges.
686
+ if (Array.isArray(query.ranges)) {
687
+ return new Promise((resolve, reject) => {
688
+ trx.onerror = event => {
689
+ this.#logger('PCache', 'readMessages', event.target.error);
690
+ reject(event.target.error);
691
+ };
692
+
693
+ let count = 0;
694
+ query.ranges.forEach(range => {
695
+ const key = range.hi ? IDBKeyRange.bound([topicName, range.low], [topicName, range.hi], false, true) :
696
+ IDBKeyRange.only([topicName, range.low]);
697
+ trx.objectStore('message').getAll(key).onsuccess = event => {
698
+ const msgs = event.target.result;
699
+ if (msgs) {
700
+ if (callback) {
701
+ callback.call(context, msgs);
702
+ }
703
+ if (Array.isArray(msgs)) {
704
+ result = result.concat(msgs);
705
+ } else {
706
+ result.push(msgs);
707
+ }
708
+ }
709
+ count++;
710
+ if (count == query.ranges.length) {
711
+ resolve(result);
712
+ }
713
+ };
714
+ });
715
+ });
716
+ }
717
+
718
+ // Handle single range.
719
+ return new Promise((resolve, reject) => {
720
+ const since = query.since > 0 ? query.since : 0;
721
+ const before = query.before > 0 ? query.before : Number.MAX_SAFE_INTEGER;
722
+ const limit = query.limit | 0;
723
+
724
+ trx.onerror = event => {
725
+ this.#logger('PCache', 'readMessages', event.target.error);
726
+ reject(event.target.error);
727
+ };
728
+
729
+ const range = IDBKeyRange.bound([topicName, since], [topicName, before], false, true);
730
+ // Iterate in descending order.
731
+ trx.objectStore('message').openCursor(range, 'prev')
732
+ .onsuccess = event => {
733
+ const cursor = event.target.result;
734
+ if (cursor) {
735
+ if (callback) {
736
+ callback.call(context, cursor.value);
737
+ }
738
+ result.push(cursor.value);
739
+ if (limit <= 0 || result.length < limit) {
740
+ cursor.continue();
741
+ } else {
742
+ resolve(result);
743
+ }
744
+ } else {
745
+ resolve(result);
746
+ }
747
+ };
748
+ });
749
+ }
750
+
751
+ // Delete log
752
+
753
+ /**
754
+ * Add records of deleted messages.
755
+ * @memberOf DB
756
+ * @param {string} topicName - name of the topic which owns the message.
757
+ * @param {number} delId - id of the deletion transaction.
758
+ * @param {Array.<IdRange>} ranges - message to save.
759
+ * @return {Promise} promise resolved/rejected on operation completion.
760
+ */
761
+ addDelLog(topicName, delId, ranges) {
762
+ // Delegate to custom storage if set
763
+ if (this.#shouldDelegate()) {
764
+ return this.#delegateStorage.addDelLog(topicName, delId, ranges);
765
+ }
766
+ if (!this.isReady()) {
767
+ return this.disabled ?
768
+ Promise.resolve() :
769
+ Promise.reject(new Error("not initialized"));
770
+ }
771
+ return new Promise((resolve, reject) => {
772
+ const trx = this.db.transaction(['dellog'], 'readwrite');
773
+ trx.onsuccess = event => {
774
+ resolve(event.target.result);
775
+ };
776
+ trx.onerror = event => {
777
+ this.#logger('PCache', 'addDelLog', event.target.error);
778
+ reject(event.target.error);
779
+ };
780
+ ranges.forEach(r => trx.objectStore('dellog').add({
781
+ topic: topicName,
782
+ clear: delId,
783
+ low: r.low,
784
+ hi: r.hi || (r.low + 1)
785
+ }));
786
+ trx.commit();
787
+ });
788
+ }
789
+
790
+ /**
791
+ * Retrieve deleted message records from persistent store.
792
+ * @memberOf DB
793
+ * @param {string} topicName - name of the topic to retrieve records for.
794
+ * @param {GetDataType} query - parameters of the message range to retrieve.
795
+ * @return {Promise} promise resolved/rejected on operation completion.
796
+ */
797
+ readDelLog(topicName, query) {
798
+ // Delegate to custom storage if set
799
+ if (this.#shouldDelegate()) {
800
+ return this.#delegateStorage.readDelLog(topicName, query);
801
+ }
802
+
803
+ query = query || {};
804
+
805
+ if (!this.isReady()) {
806
+ return this.disabled ?
807
+ Promise.resolve([]) :
808
+ Promise.reject(new Error("not initialized"));
809
+ }
810
+
811
+ const trx = this.db.transaction(['dellog']);
812
+ let result = [];
813
+
814
+ // Handle individual message ranges.
815
+ if (Array.isArray(query.ranges)) {
816
+ return new Promise((resolve, reject) => {
817
+ trx.onerror = event => {
818
+ this.#logger('PCache', 'readDelLog', event.target.error);
819
+ reject(event.target.error);
820
+ };
821
+
822
+ let count = 0;
823
+ query.ranges.forEach(range => {
824
+ const hi = range.hi || (range.low + 1);
825
+ const key = IDBKeyRange.bound([topicName, 0, range.low], [topicName, hi, Number.MAX_SAFE_INTEGER], false, true);
826
+ trx.objectStore('dellog').getAll(key).onsuccess = event => {
827
+ const entries = event.target.result;
828
+ if (entries) {
829
+ if (Array.isArray(entries)) {
830
+ result = result.concat(entries.map(entry => {
831
+ return {
832
+ low: entry.low,
833
+ hi: entry.hi
834
+ };
835
+ }));
836
+ } else {
837
+ result.push({
838
+ low: entries.low,
839
+ hi: entries.hi
840
+ });
841
+ }
842
+ }
843
+ count++;
844
+ if (count == query.ranges.length) {
845
+ resolve(result);
846
+ }
847
+ };
848
+ });
849
+ });
850
+ }
851
+
852
+ return new Promise((resolve, reject) => {
853
+ const since = query.since > 0 ? query.since : 0;
854
+ const before = query.before > 0 ? query.before : Number.MAX_SAFE_INTEGER;
855
+ const limit = query.limit | 0;
856
+
857
+ trx.onerror = event => {
858
+ this.#logger('PCache', 'readDelLog', event.target.error);
859
+ reject(event.target.error);
860
+ };
861
+
862
+ let count = 0;
863
+ const result = [];
864
+ const range = IDBKeyRange.bound([topicName, 0, since], [topicName, before, Number.MAX_SAFE_INTEGER], false, true);
865
+ trx.objectStore('dellog').openCursor(range, 'prev')
866
+ .onsuccess = event => {
867
+ const cursor = event.target.result;
868
+ if (cursor) {
869
+ result.push({
870
+ low: cursor.value.low,
871
+ hi: cursor.value.hi
872
+ });
873
+ count += cursor.value.hi - cursor.value.low;
874
+ if (limit <= 0 || count < limit) {
875
+ cursor.continue();
876
+ } else {
877
+ resolve(result);
878
+ }
879
+ } else {
880
+ resolve(result);
881
+ }
882
+ };
883
+ });
884
+ }
885
+
886
+ /**
887
+ * Retrieve the latest 'clear' ID for the given topic.
888
+ * @param {string} topicName
889
+ * @return {Promise} promise resolved/rejected on operation completion.
890
+ */
891
+ maxDelId(topicName) {
892
+ // Delegate to custom storage if set
893
+ if (this.#shouldDelegate()) {
894
+ return this.#delegateStorage.maxDelId(topicName);
895
+ }
896
+ if (!this.isReady()) {
897
+ return this.disabled ?
898
+ Promise.resolve(0) :
899
+ Promise.reject(new Error("not initialized"));
900
+ }
901
+
902
+ return new Promise((resolve, reject) => {
903
+ const trx = this.db.transaction(['dellog']);
904
+ trx.onerror = event => {
905
+ this.#logger('PCache', 'maxDelId', event.target.error);
906
+ reject(event.target.error);
907
+ };
908
+
909
+ const index = trx.objectStore('dellog').index('topic_clear');
910
+ index.openCursor(IDBKeyRange.bound([topicName, 0], [topicName, Number.MAX_SAFE_INTEGER]), 'prev')
911
+ .onsuccess = event => {
912
+ if (event.target.result) {
913
+ resolve(event.target.result.value);
914
+ }
915
+ };
916
+ });
917
+ }
918
+
919
+ // Private methods.
920
+
921
+ // Serializable topic fields.
922
+ static #topic_fields = ['created', 'updated', 'deleted', 'touched', 'read', 'recv', 'seq',
923
+ 'clear', 'defacs', 'creds', 'public', 'trusted', 'private', '_aux', '_deleted'
924
+ ];
925
+
926
+ // Copy data from src to Topic object.
927
+ static #deserializeTopic(topic, src) {
928
+ DB.#topic_fields.forEach((f) => {
929
+ if (src.hasOwnProperty(f)) {
930
+ topic[f] = src[f];
931
+ }
932
+ });
933
+ if (Array.isArray(src.tags)) {
934
+ topic._tags = src.tags;
935
+ }
936
+ if (src.acs) {
937
+ topic.setAccessMode(src.acs);
938
+ }
939
+ topic.seq |= 0;
940
+ topic.read |= 0;
941
+ topic.unread = Math.max(0, topic.seq - topic.read);
942
+ }
943
+
944
+ // Copy values from 'src' to 'dst'. Allocate dst if it's null or undefined.
945
+ static #serializeTopic(dst, src) {
946
+ const res = dst || {
947
+ name: src.name
948
+ };
949
+ DB.#topic_fields.forEach(f => {
950
+ if (src.hasOwnProperty(f)) {
951
+ res[f] = src[f];
952
+ }
953
+ });
954
+ if (Array.isArray(src._tags)) {
955
+ res.tags = src._tags;
956
+ }
957
+ if (src.acs) {
958
+ res.acs = src.getAccessMode().jsonHelper();
959
+ }
960
+ return res;
961
+ }
962
+
963
+ static #serializeSubscription(dst, topicName, uid, sub) {
964
+ const fields = ['updated', 'mode', 'read', 'recv', 'clear', 'lastSeen', 'userAgent'];
965
+ const res = dst || {
966
+ topic: topicName,
967
+ uid: uid
968
+ };
969
+
970
+ fields.forEach((f) => {
971
+ if (sub.hasOwnProperty(f)) {
972
+ res[f] = sub[f];
973
+ }
974
+ });
975
+
976
+ return res;
977
+ }
978
+
979
+ static #serializeMessage(dst, msg) {
980
+ // Serializable fields.
981
+ const fields = ['topic', 'seq', 'ts', '_status', 'from', 'head', 'content'];
982
+ const res = dst || {};
983
+ fields.forEach((f) => {
984
+ if (msg.hasOwnProperty(f)) {
985
+ res[f] = msg[f];
986
+ }
987
+ });
988
+ return res;
989
+ }
990
+
991
+ /**
992
+ * To use DB in a non browser context, supply indexedDB provider.
993
+ * @static
994
+ * @memberof DB
995
+ * @param idbProvider indexedDB provider, e.g. for node <code>require('fake-indexeddb')</code>.
996
+ */
997
+ static setDatabaseProvider(idbProvider) {
998
+ IDBProvider = idbProvider;
999
+ }
1000
+
1001
+ /**
1002
+ * Set a custom storage provider (e.g., SQLiteStorage for React Native).
1003
+ * Must be called BEFORE creating Tinode instance with persist: true.
1004
+ * @static
1005
+ * @memberof DB
1006
+ * @param {Object} storage - Storage implementation with the same interface as DB class.
1007
+ */
1008
+ static setStorageProvider(storage) {
1009
+ _storageProvider = storage;
1010
+ }
1011
+
1012
+ /**
1013
+ * Get the current storage provider (if any).
1014
+ * @static
1015
+ * @memberof DB
1016
+ * @returns {Object|null} The custom storage provider, or null if using default IndexedDB.
1017
+ */
1018
+ static getStorageProvider() {
1019
+ return _storageProvider;
1020
+ }
1021
+ }