@massalabs/gossip-sdk 0.0.2-dev.20260220052333 → 0.0.2-dev.20260220053835

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.
Files changed (60) hide show
  1. package/README.md +2 -2
  2. package/dist/config/sdk.js +1 -1
  3. package/dist/contacts.d.ts +11 -17
  4. package/dist/contacts.js +20 -26
  5. package/dist/core/SdkEventEmitter.d.ts +2 -0
  6. package/dist/core/SdkEventEmitter.js +2 -0
  7. package/dist/core/SdkPolling.d.ts +2 -5
  8. package/dist/core/SdkPolling.js +1 -4
  9. package/dist/core/index.d.ts +1 -1
  10. package/dist/core/index.js +1 -0
  11. package/dist/db.d.ts +34 -56
  12. package/dist/db.js +39 -229
  13. package/dist/gossip.d.ts +54 -23
  14. package/dist/gossip.js +149 -120
  15. package/dist/index.d.ts +13 -4
  16. package/dist/index.js +13 -4
  17. package/dist/queries/activeSeekers.d.ts +2 -0
  18. package/dist/queries/activeSeekers.js +18 -0
  19. package/dist/queries/announcementCursors.d.ts +2 -0
  20. package/dist/queries/announcementCursors.js +20 -0
  21. package/dist/queries/contacts.d.ts +13 -0
  22. package/dist/queries/contacts.js +43 -0
  23. package/dist/queries/discussions.d.ts +21 -0
  24. package/dist/queries/discussions.js +73 -0
  25. package/dist/queries/index.d.ts +7 -0
  26. package/dist/queries/index.js +7 -0
  27. package/dist/queries/messages.d.ts +27 -0
  28. package/dist/queries/messages.js +120 -0
  29. package/dist/queries/pendingAnnouncements.d.ts +4 -0
  30. package/dist/queries/pendingAnnouncements.js +11 -0
  31. package/dist/queries/userProfile.d.ts +22 -0
  32. package/dist/queries/userProfile.js +116 -0
  33. package/dist/schema.d.ts +1280 -0
  34. package/dist/schema.js +164 -0
  35. package/dist/services/announcement.d.ts +8 -7
  36. package/dist/services/announcement.js +95 -121
  37. package/dist/services/auth.d.ts +1 -3
  38. package/dist/services/auth.js +4 -11
  39. package/dist/services/discussion.d.ts +8 -15
  40. package/dist/services/discussion.js +62 -62
  41. package/dist/services/message.d.ts +15 -26
  42. package/dist/services/message.js +282 -270
  43. package/dist/services/refresh.d.ts +3 -5
  44. package/dist/services/refresh.js +10 -13
  45. package/dist/sqlite-worker.d.ts +12 -0
  46. package/dist/sqlite-worker.js +106 -0
  47. package/dist/sqlite.d.ts +79 -0
  48. package/dist/sqlite.js +448 -0
  49. package/dist/utils/contacts.d.ts +2 -5
  50. package/dist/utils/contacts.js +23 -39
  51. package/dist/utils/discussions.d.ts +7 -2
  52. package/dist/utils/discussions.js +24 -5
  53. package/dist/utils/logs.js +1 -3
  54. package/dist/utils/validation.d.ts +2 -4
  55. package/dist/utils/validation.js +5 -10
  56. package/package.json +5 -7
  57. package/dist/api/messageProtocol/mock.d.ts +0 -12
  58. package/dist/api/messageProtocol/mock.js +0 -12
  59. package/dist/types/events.d.ts +0 -80
  60. package/dist/types/events.js +0 -7
@@ -5,24 +5,98 @@
5
5
  * This service works both in host app contexts and SDK/automation context.
6
6
  */
7
7
  import { MessageDirection, MessageStatus, MessageType, MESSAGE_ID_SIZE, } from '../db';
8
+ import { getMessageById, getMessageByOwnerAndSeeker, findMessageByMessageId, insertMessage, updateMessageById, deleteDeliveredKeepAliveMessages, getOutgoingSentMessagesByOwner, getWaitingMessageCount as getWaitingCount, getSendQueueMessages, } from '../queries';
9
+ import { getDiscussionByOwnerAndContact, updateDiscussionById, incrementUnreadCount, decrementUnreadCount, } from '../queries';
10
+ import { replaceActiveSeekers } from '../queries';
8
11
  import { decodeUserId, encodeUserId } from '../utils/userId';
9
12
  import { SessionStatus } from '../wasm/bindings';
10
13
  import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, deserializeMessage, } from '../utils/messageSerialization';
11
- import { encodeToBase64 } from '../utils/base64';
14
+ import { encodeToBase64, decodeFromBase64 } from '../utils/base64';
12
15
  import { sessionStatusToString } from '../wasm/session';
13
16
  import { Logger } from '../utils/logs';
14
17
  import { defaultSdkConfig } from '../config/sdk';
15
18
  import { SdkEventType } from '../core/SdkEventEmitter';
19
+ // ---------------------------------------------------------------------------
20
+ // JSON serialization helpers for message fields stored as text in SQLite
21
+ // ---------------------------------------------------------------------------
22
+ /** Serialize replyTo to JSON string for SQLite storage.
23
+ * Uint8Array fields are base64-encoded. */
24
+ function serializeReplyTo(replyTo) {
25
+ if (!replyTo)
26
+ return null;
27
+ return JSON.stringify({
28
+ originalMsgId: encodeToBase64(replyTo.originalMsgId),
29
+ });
30
+ }
31
+ /** Deserialize replyTo from JSON string. */
32
+ function deserializeReplyTo(json) {
33
+ if (!json)
34
+ return undefined;
35
+ const parsed = JSON.parse(json);
36
+ return {
37
+ originalMsgId: decodeFromBase64(parsed.originalMsgId),
38
+ };
39
+ }
40
+ /** Serialize forwardOf to JSON string for SQLite storage. */
41
+ function serializeForwardOf(forwardOf) {
42
+ if (!forwardOf)
43
+ return null;
44
+ return JSON.stringify({
45
+ originalContent: forwardOf.originalContent,
46
+ originalContactId: forwardOf.originalContactId
47
+ ? encodeToBase64(forwardOf.originalContactId)
48
+ : undefined,
49
+ });
50
+ }
51
+ /** Deserialize forwardOf from JSON string. */
52
+ function deserializeForwardOf(json) {
53
+ if (!json)
54
+ return undefined;
55
+ const parsed = JSON.parse(json);
56
+ return {
57
+ originalContent: parsed.originalContent ?? undefined,
58
+ originalContactId: parsed.originalContactId
59
+ ? decodeFromBase64(parsed.originalContactId)
60
+ : undefined,
61
+ };
62
+ }
63
+ /** Serialize metadata to JSON string. */
64
+ function serializeMetadata(metadata) {
65
+ if (!metadata)
66
+ return null;
67
+ return JSON.stringify(metadata);
68
+ }
69
+ /** Deserialize metadata from JSON string. */
70
+ function deserializeMetadata(json) {
71
+ if (!json)
72
+ return undefined;
73
+ return JSON.parse(json);
74
+ }
75
+ /** Convert a SQLite row from the messages table to a Message object. */
76
+ export function rowToMessage(row) {
77
+ return {
78
+ id: row.id,
79
+ messageId: row.messageId ?? undefined,
80
+ ownerUserId: row.ownerUserId,
81
+ contactUserId: row.contactUserId,
82
+ content: row.content,
83
+ serializedContent: row.serializedContent ?? undefined,
84
+ type: row.type,
85
+ direction: row.direction,
86
+ status: row.status,
87
+ timestamp: row.timestamp,
88
+ metadata: deserializeMetadata(row.metadata),
89
+ seeker: row.seeker ?? undefined,
90
+ replyTo: deserializeReplyTo(row.replyTo),
91
+ forwardOf: deserializeForwardOf(row.forwardOf),
92
+ encryptedMessage: row.encryptedMessage ?? undefined,
93
+ whenToSend: row.whenToSend ?? undefined,
94
+ };
95
+ }
16
96
  const sleep = (ms) => new Promise(res => setTimeout(res, ms));
17
97
  const logger = new Logger('MessageService');
18
98
  export class MessageService {
19
- constructor(db, messageProtocol, session, discussionService, eventEmitter, config = defaultSdkConfig) {
20
- Object.defineProperty(this, "db", {
21
- enumerable: true,
22
- configurable: true,
23
- writable: true,
24
- value: void 0
25
- });
99
+ constructor(messageProtocol, session, eventEmitter, config = defaultSdkConfig) {
26
100
  Object.defineProperty(this, "messageProtocol", {
27
101
  enumerable: true,
28
102
  configurable: true,
@@ -35,19 +109,19 @@ export class MessageService {
35
109
  writable: true,
36
110
  value: void 0
37
111
  });
38
- Object.defineProperty(this, "discussionService", {
112
+ Object.defineProperty(this, "eventEmitter", {
39
113
  enumerable: true,
40
114
  configurable: true,
41
115
  writable: true,
42
116
  value: void 0
43
117
  });
44
- Object.defineProperty(this, "eventEmitter", {
118
+ Object.defineProperty(this, "config", {
45
119
  enumerable: true,
46
120
  configurable: true,
47
121
  writable: true,
48
122
  value: void 0
49
123
  });
50
- Object.defineProperty(this, "config", {
124
+ Object.defineProperty(this, "refreshService", {
51
125
  enumerable: true,
52
126
  configurable: true,
53
127
  writable: true,
@@ -65,13 +139,13 @@ export class MessageService {
65
139
  writable: true,
66
140
  value: false
67
141
  });
68
- this.db = db;
69
142
  this.messageProtocol = messageProtocol;
70
143
  this.session = session;
71
- this.discussionService = discussionService;
72
144
  this.eventEmitter = eventEmitter;
73
145
  this.config = config;
74
- void this.discussionService;
146
+ }
147
+ setRefreshService(refreshService) {
148
+ this.refreshService = refreshService;
75
149
  }
76
150
  async fetchMessages() {
77
151
  const log = logger.forMethod('fetchMessages');
@@ -129,7 +203,7 @@ export class MessageService {
129
203
  await sleep(this.config.messages.fetchDelayMs);
130
204
  }
131
205
  try {
132
- await this.db.setActiveSeekers(seekers);
206
+ await replaceActiveSeekers(seekers);
133
207
  }
134
208
  catch (error) {
135
209
  log.error('failed to update active seekers', error);
@@ -151,6 +225,41 @@ export class MessageService {
151
225
  this.isFetchingMessages = false;
152
226
  }
153
227
  }
228
+ /**
229
+ * Add a message to SQLite and update the corresponding discussion.
230
+ */
231
+ async addMessageAndUpdateDiscussion(message) {
232
+ const messageId = await insertMessage({
233
+ messageId: message.messageId,
234
+ ownerUserId: message.ownerUserId,
235
+ contactUserId: message.contactUserId,
236
+ content: message.content,
237
+ serializedContent: message.serializedContent,
238
+ type: message.type,
239
+ direction: message.direction,
240
+ status: message.status,
241
+ timestamp: message.timestamp,
242
+ metadata: serializeMetadata(message.metadata),
243
+ seeker: message.seeker,
244
+ replyTo: serializeReplyTo(message.replyTo),
245
+ forwardOf: serializeForwardOf(message.forwardOf),
246
+ encryptedMessage: message.encryptedMessage,
247
+ whenToSend: message.whenToSend,
248
+ });
249
+ const discussion = await getDiscussionByOwnerAndContact(message.ownerUserId, message.contactUserId);
250
+ if (discussion) {
251
+ await updateDiscussionById(discussion.id, {
252
+ lastMessageId: messageId,
253
+ lastMessageContent: message.content,
254
+ lastMessageTimestamp: message.timestamp,
255
+ updatedAt: new Date(),
256
+ });
257
+ if (message.direction === MessageDirection.INCOMING) {
258
+ await incrementUnreadCount(discussion.id);
259
+ }
260
+ }
261
+ return messageId;
262
+ }
154
263
  async decryptMessages(encrypted) {
155
264
  const log = logger.forMethod('decryptMessages');
156
265
  const decrypted = [];
@@ -207,187 +316,117 @@ export class MessageService {
207
316
  const log = logger.forMethod('storeDecryptedMessages');
208
317
  const storedIds = [];
209
318
  for (const message of decrypted) {
210
- const result = await this.db.transaction('rw', this.db.messages, this.db.discussions, async () => {
211
- const discussion = await this.db.getDiscussionByOwnerAndContact(ownerUserId, message.senderId);
212
- if (!discussion) {
213
- log.error('no discussion for incoming message', {
214
- senderId: message.senderId,
215
- preview: message.content.slice(0, 50),
216
- });
217
- return null;
218
- }
219
- // If received msg has same messageId as a previously received msg
220
- const isDuplicate = await this.handleDuplicateMessageId(message, ownerUserId);
221
- if (isDuplicate) {
222
- log.info('Duplicate message received, skipping', {
223
- senderId: message.senderId,
224
- preview: message.content.slice(0, 30),
225
- });
226
- return null;
227
- }
228
- if (message.replyTo?.originalMsgId) {
229
- const original = await this.findMessageByMsgId(message.replyTo.originalMsgId, ownerUserId, message.senderId);
230
- if (!original) {
231
- log.warn('reply target not found', {
232
- originalMsgId: encodeToBase64(message.replyTo.originalMsgId),
233
- });
234
- }
235
- }
236
- const id = await this.db.messages.add({
237
- ownerUserId,
238
- contactUserId: discussion.contactUserId,
239
- content: message.content,
240
- type: message.type,
241
- direction: MessageDirection.INCOMING,
242
- status: MessageStatus.DELIVERED,
243
- timestamp: message.sentAt,
244
- metadata: {},
245
- messageId: message.messageId,
246
- replyTo: message.replyTo
247
- ? {
248
- originalMsgId: message.replyTo.originalMsgId,
249
- }
250
- : undefined,
251
- forwardOf: message.forwardOf
252
- ? {
253
- originalContent: message.forwardOf.originalContent,
254
- originalContactId: message.forwardOf.originalContactId,
255
- }
256
- : undefined,
319
+ const discussion = await getDiscussionByOwnerAndContact(ownerUserId, message.senderId);
320
+ if (!discussion) {
321
+ log.error('no discussion for incoming message', {
322
+ senderId: message.senderId,
323
+ preview: message.content.slice(0, 50),
257
324
  });
258
- const now = new Date();
259
- await this.db.discussions.update(discussion.id, {
260
- lastMessageId: id,
261
- lastMessageContent: message.content,
262
- lastMessageTimestamp: message.sentAt,
263
- updatedAt: now,
264
- lastSyncTimestamp: now,
265
- unreadCount: discussion.unreadCount + 1,
325
+ continue;
326
+ }
327
+ // If received msg has same messageId as a previously received msg
328
+ const isDuplicate = await this.handleDuplicateMessageId(message, ownerUserId);
329
+ if (isDuplicate) {
330
+ log.info('Duplicate message received, skipping', {
331
+ senderId: message.senderId,
332
+ preview: message.content.slice(0, 30),
266
333
  });
267
- return id;
268
- });
269
- if (result === null) {
270
334
  continue;
271
335
  }
272
- const id = result;
336
+ if (message.replyTo?.originalMsgId) {
337
+ const original = await this.findMessageByMsgId(message.replyTo.originalMsgId, ownerUserId, message.senderId);
338
+ if (!original) {
339
+ log.warn('reply target not found', {
340
+ originalMsgId: encodeToBase64(message.replyTo.originalMsgId),
341
+ });
342
+ }
343
+ }
344
+ const id = await insertMessage({
345
+ messageId: message.messageId,
346
+ ownerUserId,
347
+ contactUserId: discussion.contactUserId,
348
+ content: message.content,
349
+ type: message.type,
350
+ direction: MessageDirection.INCOMING,
351
+ status: MessageStatus.DELIVERED,
352
+ timestamp: message.sentAt,
353
+ metadata: serializeMetadata({}),
354
+ replyTo: serializeReplyTo(message.replyTo),
355
+ forwardOf: serializeForwardOf(message.forwardOf),
356
+ });
357
+ // Update discussion in SQLite
358
+ const now = new Date();
359
+ await updateDiscussionById(discussion.id, {
360
+ lastMessageId: id,
361
+ lastMessageContent: message.content,
362
+ lastMessageTimestamp: message.sentAt,
363
+ updatedAt: now,
364
+ lastSyncTimestamp: now,
365
+ });
366
+ await incrementUnreadCount(discussion.id);
273
367
  storedIds.push(id);
274
368
  // Emit event for new message
275
- const storedMessage = await this.db.messages.get(id);
276
- if (storedMessage) {
277
- this.eventEmitter.emit(SdkEventType.MESSAGE_RECEIVED, storedMessage);
369
+ const row = await getMessageById(id);
370
+ if (row) {
371
+ this.eventEmitter.emit(SdkEventType.MESSAGE_RECEIVED, rowToMessage(row));
278
372
  }
279
373
  }
280
374
  return storedIds;
281
375
  }
282
376
  /**
283
- * Checks for duplicate incoming messages in the message database based on messageId.
284
- *
285
- * A message is considered a duplicate if:
286
- * - It has the same messageId
287
- * - It is from the same sender (contactUserId)
288
- * - Belongs to the same conversation (ownerUserId)
289
- *
290
- * This protects against scenarios where:
291
- * 1. A sender successfully delivers a message to the network,
292
- * 2. Their app crashes or resets before marking the message as SENT in the local DB,
293
- * 3. On restart, the sender's app re-sends the message (possibly with a different seeker),
294
- * 4. The receiver gets the same message twice,
295
- * 5. Duplicates are prevented by matching messageId byte-for-byte.
296
- *
297
- * This method uses messageId (usually a 12-byte random value) for exact match detection.
298
- * If a duplicate is found, it updates certain fields (e.g., content, replyTo, forwardOf)
299
- * on the existing message and returns true.
300
- *
301
- * @param message - The incoming decrypted message object.
302
- * @param ownerUserId - The userId of the message database owner (the recipient).
303
- * @returns true if a duplicate message (by messageId) was found; otherwise false.
377
+ * Checks for duplicate incoming messages based on messageId (12-byte random).
378
+ * Returns true if a message with the same messageId already exists (skips storage).
304
379
  */
305
380
  async handleDuplicateMessageId(message, ownerUserId) {
306
- // Check for duplicate message using messageId (12 bytes random)
307
- if (message.messageId) {
308
- const existingWithMessageId = await this.db.messages
309
- .where('[ownerUserId+contactUserId]')
310
- .equals([ownerUserId, message.senderId])
311
- .and(m => {
312
- if (!m.messageId || !message.messageId)
313
- return false;
314
- if (m.messageId.length !== message.messageId.length)
315
- return false;
316
- for (let i = 0; i < m.messageId.length; i++) {
317
- if (m.messageId[i] !== message.messageId[i])
318
- return false;
319
- }
320
- return true;
321
- })
322
- .first();
323
- if (existingWithMessageId) {
324
- await this.db.messages.update(existingWithMessageId.id, {
325
- content: message.content,
326
- replyTo: message.replyTo,
327
- forwardOf: message.forwardOf,
328
- });
381
+ if (message.messageId && message.messageId.length > 0) {
382
+ const existing = await findMessageByMessageId(ownerUserId, message.senderId, message.messageId);
383
+ if (existing) {
329
384
  return true;
330
385
  }
331
386
  }
332
387
  return false;
333
388
  }
334
389
  async findMessageByMsgId(messageId, ownerUserId, contactUserId) {
335
- const baseQuery = contactUserId
336
- ? this.db.messages
337
- .where('[ownerUserId+contactUserId]')
338
- .equals([ownerUserId, contactUserId])
339
- : this.db.messages.where('ownerUserId').equals(ownerUserId);
340
- return await baseQuery
341
- .and(m => {
342
- if (!m.messageId || m.messageId.length !== messageId.length) {
343
- return false;
344
- }
345
- for (let i = 0; i < messageId.length; i++) {
346
- if (m.messageId[i] !== messageId[i])
347
- return false;
348
- }
349
- return true;
350
- })
351
- .first();
390
+ if (!contactUserId) {
391
+ // Without contactUserId, fall back to seeker-based lookup is not possible
392
+ // with messageId. Return undefined.
393
+ return undefined;
394
+ }
395
+ const row = await findMessageByMessageId(ownerUserId, contactUserId, messageId);
396
+ return row ? rowToMessage(row) : undefined;
397
+ }
398
+ async findMessageBySeeker(seeker, ownerUserId) {
399
+ const row = await getMessageByOwnerAndSeeker(ownerUserId, seeker);
400
+ return row ? rowToMessage(row) : undefined;
352
401
  }
353
402
  async acknowledgeMessages(seekers, userId) {
354
403
  if (seekers.size === 0)
355
404
  return;
356
- const updatedCount = await this.db.transaction('rw', this.db.messages, async () => {
357
- // Mark matching OUTGOING SENT messages as DELIVERED
358
- const count = await this.db.messages
359
- .where('[ownerUserId+direction+status]')
360
- .equals([userId, MessageDirection.OUTGOING, MessageStatus.SENT])
361
- .filter(message => message.seeker !== undefined &&
362
- seekers.has(encodeToBase64(message.seeker)))
363
- .modify({
405
+ // Find SENT outgoing messages, then filter by seeker match
406
+ const candidates = await getOutgoingSentMessagesByOwner(userId);
407
+ const toUpdate = candidates.filter(m => m.seeker != null && seekers.has(encodeToBase64(m.seeker)));
408
+ // Mark matching OUTGOING SENT messages as DELIVERED and clear encrypted data
409
+ for (const m of toUpdate) {
410
+ await updateMessageById(m.id, {
364
411
  status: MessageStatus.DELIVERED,
365
- serializedContent: undefined,
366
- seeker: undefined,
412
+ encryptedMessage: null,
413
+ serializedContent: null,
414
+ seeker: null,
415
+ whenToSend: null,
367
416
  });
368
- // After marking as DELIVERED, clean up DELIVERED keep-alive messages
369
- await this.db.messages
370
- .where({
371
- ownerUserId: userId,
372
- status: MessageStatus.DELIVERED,
373
- type: MessageType.KEEP_ALIVE,
374
- })
375
- .delete();
376
- return count;
377
- });
378
- if (updatedCount > 0) {
417
+ }
418
+ // After marking as DELIVERED, clean up DELIVERED keep-alive messages
419
+ await deleteDeliveredKeepAliveMessages(userId);
420
+ if (toUpdate.length > 0) {
379
421
  logger
380
422
  .forMethod('acknowledgeMessages')
381
- .info(`acknowledged ${updatedCount} messages`);
423
+ .info(`acknowledged ${toUpdate.length} messages`);
382
424
  }
383
425
  }
384
426
  async sendMessage(message) {
385
427
  const log = logger.forMethod('sendMessage');
386
428
  log.info('queueing message', {
387
- messageContent: message.content,
388
429
  messageType: message.type,
389
- messageReplyTo: message.replyTo,
390
- messageForwardOf: message.forwardOf,
391
430
  });
392
431
  const peerId = decodeUserId(message.contactUserId);
393
432
  if (peerId.length !== 32) {
@@ -396,37 +435,40 @@ export class MessageService {
396
435
  error: 'Invalid contact userId (must be 32 bytes)',
397
436
  };
398
437
  }
399
- // Run getDiscussionByOwnerAndContact and addMessage in a transaction
400
- let discussion;
438
+ // Look up discussion
439
+ const discussion = await getDiscussionByOwnerAndContact(message.ownerUserId, message.contactUserId);
440
+ if (!discussion) {
441
+ return { success: false, error: 'Discussion not found' };
442
+ }
443
+ // Generate a random messageId for deduplication (not for keep-alive)
444
+ const randomMessageId = message.type !== MessageType.KEEP_ALIVE
445
+ ? crypto.getRandomValues(new Uint8Array(MESSAGE_ID_SIZE))
446
+ : undefined;
447
+ message.messageId = randomMessageId;
448
+ // Add message as WAITING_SESSION
401
449
  let messageId;
402
- const error = await this.db.transaction('rw', this.db.discussions, this.db.messages, async () => {
403
- discussion = await this.db.getDiscussionByOwnerAndContact(message.ownerUserId, message.contactUserId);
404
- if (!discussion) {
405
- // If the discussion doesn't exist (it may have been deleted by the user), Dexie aborts the transaction.
406
- return new Error('Discussion not found');
407
- }
408
- try {
409
- const randomMessageId = message.type !== MessageType.KEEP_ALIVE
410
- ? crypto.getRandomValues(new Uint8Array(MESSAGE_ID_SIZE))
411
- : undefined;
412
- message.messageId = randomMessageId;
413
- messageId = await this.db.addMessage({
414
- ...message,
415
- status: MessageStatus.WAITING_SESSION,
416
- });
417
- }
418
- catch (error) {
419
- return new Error('Failed to add message to database, got error: ' + error);
420
- }
421
- });
422
- if (error) {
423
- return { success: false, error: error.message };
450
+ try {
451
+ messageId = await this.addMessageAndUpdateDiscussion({
452
+ ...message,
453
+ status: MessageStatus.WAITING_SESSION,
454
+ });
455
+ }
456
+ catch (error) {
457
+ return {
458
+ success: false,
459
+ error: 'Failed to add message to database, got error: ' + error,
460
+ };
424
461
  }
425
462
  const queuedMessage = {
426
463
  ...message,
427
464
  id: messageId,
428
465
  status: MessageStatus.WAITING_SESSION,
429
466
  };
467
+ /*
468
+ Trigger a state update to send the new message.
469
+ If the stateUpdate function is already running, it will be skipped.
470
+ */
471
+ await this.refreshService?.stateUpdate();
430
472
  return {
431
473
  success: true,
432
474
  message: queuedMessage,
@@ -490,11 +532,11 @@ export class MessageService {
490
532
  for (const msg of retryMessages) {
491
533
  if (!msg.id)
492
534
  continue;
493
- await this.db.messages.update(msg.id, {
535
+ await updateMessageById(msg.id, {
494
536
  status: MessageStatus.WAITING_SESSION,
495
- encryptedMessage: undefined,
496
- seeker: undefined,
497
- whenToSend: undefined,
537
+ encryptedMessage: null,
538
+ seeker: null,
539
+ whenToSend: null,
498
540
  });
499
541
  }
500
542
  await this.processSendQueueForContact(contactId);
@@ -517,7 +559,7 @@ export class MessageService {
517
559
  }
518
560
  this.processingContacts.add(contactUserId);
519
561
  try {
520
- const discussion = await this.db.getDiscussionByOwnerAndContact(ownerUserId, contactUserId);
562
+ const discussion = await getDiscussionByOwnerAndContact(ownerUserId, contactUserId);
521
563
  const weAccepted = discussion?.weAccepted;
522
564
  if (!discussion || !weAccepted) {
523
565
  return {
@@ -537,13 +579,8 @@ export class MessageService {
537
579
  error: new Error('Session not active or self requested'),
538
580
  };
539
581
  }
540
- // retrieve all message in send queue that need to be updated for this contact
541
- const pendingMessages = await this.db.messages
542
- .where('[ownerUserId+contactUserId]')
543
- .equals([ownerUserId, contactUserId])
544
- .and(msg => msg.direction === MessageDirection.OUTGOING &&
545
- [MessageStatus.WAITING_SESSION, MessageStatus.READY].includes(msg.status))
546
- .sortBy('timestamp');
582
+ // retrieve all messages in send queue that need to be updated for this contact
583
+ const pendingMessages = (await getSendQueueMessages(ownerUserId, contactUserId)).map(rowToMessage);
547
584
  log.debug('pending messages', {
548
585
  pendingMessages: pendingMessages.map(msg => ({
549
586
  id: msg.id,
@@ -592,7 +629,7 @@ export class MessageService {
592
629
  encryptedMessage = sendOutput.data;
593
630
  seeker = sendOutput.seeker;
594
631
  whenToSend = new Date();
595
- await this.db.messages.update(msg.id, {
632
+ await updateMessageById(msg.id, {
596
633
  status: MessageStatus.READY,
597
634
  encryptedMessage,
598
635
  seeker,
@@ -622,11 +659,11 @@ export class MessageService {
622
659
  continue;
623
660
  }
624
661
  if (!encryptedMessage || !seeker) {
625
- await this.db.messages.update(msg.id, {
662
+ await updateMessageById(msg.id, {
626
663
  status: MessageStatus.WAITING_SESSION,
627
- encryptedMessage: undefined,
628
- seeker: undefined,
629
- whenToSend: undefined,
664
+ encryptedMessage: null,
665
+ seeker: null,
666
+ whenToSend: null,
630
667
  });
631
668
  log.debug('message has no encryptedMessage or seeker, updated to WAITING_SESSION', {
632
669
  messageId: msg.id,
@@ -646,30 +683,20 @@ export class MessageService {
646
683
  seeker,
647
684
  ciphertext: encryptedMessage,
648
685
  });
649
- // update the db
650
- const sent = await this.db.transaction('rw', this.db.messages, async () => {
651
- try {
652
- const latest = await this.db.messages.get(msg.id);
653
- if (latest && // ensure the discussion has not been deleted
654
- latest.status === MessageStatus.READY // ensure the discussion has not been reset with all pending messages reset to WAITING_SESSION
655
- ) {
656
- await this.db.messages.update(msg.id, {
657
- status: MessageStatus.SENT,
658
- encryptedMessage: undefined,
659
- whenToSend: undefined,
660
- });
661
- return true;
662
- }
663
- }
664
- catch (error) {
665
- log.error('failed to update message status to SENT', {
666
- messageId: msg.id,
667
- error,
668
- });
669
- return false;
670
- }
671
- return false;
672
- });
686
+ // update the db — check latest state to avoid race conditions
687
+ const latestRow = await getMessageById(msg.id);
688
+ let sent = false;
689
+ if (latestRow && // ensure the discussion has not been deleted
690
+ latestRow.status === MessageStatus.READY // ensure the discussion has not been reset with all pending messages reset to WAITING_SESSION
691
+ ) {
692
+ await updateMessageById(msg.id, {
693
+ status: MessageStatus.SENT,
694
+ encryptedMessage: null,
695
+ serializedContent: null,
696
+ whenToSend: null,
697
+ });
698
+ sent = true;
699
+ }
673
700
  if (sent) {
674
701
  sentCount++;
675
702
  log.debug('message sent', {
@@ -698,16 +725,14 @@ export class MessageService {
698
725
  messageId: msg.id,
699
726
  error,
700
727
  });
701
- await this.db.transaction('rw', this.db.messages, async () => {
702
- const latest = await this.db.messages.get(msg.id);
703
- if (latest && // ensure the discussion has not been deleted
704
- latest.status === MessageStatus.READY // ensure the discussion has not been reset with all pending messages reset to WAITING_SESSION
705
- ) {
706
- await this.db.messages.update(msg.id, {
707
- whenToSend: new Date(Date.now() + this.config.messages.retryDelayMs),
708
- });
709
- }
710
- });
728
+ const latestRow = await getMessageById(msg.id);
729
+ if (latestRow && // ensure the discussion has not been deleted
730
+ latestRow.status === MessageStatus.READY // ensure the discussion has not been reset with all pending messages reset to WAITING_SESSION
731
+ ) {
732
+ await updateMessageById(msg.id, {
733
+ whenToSend: new Date(Date.now() + this.config.messages.retryDelayMs),
734
+ });
735
+ }
711
736
  }
712
737
  }
713
738
  }
@@ -722,12 +747,8 @@ export class MessageService {
722
747
  */
723
748
  async getPendingSendCount(contactUserId) {
724
749
  const ownerUserId = this.session.userIdEncoded;
725
- return await this.db.messages
726
- .where('[ownerUserId+contactUserId]')
727
- .equals([ownerUserId, contactUserId])
728
- .and(msg => msg.direction === MessageDirection.OUTGOING &&
729
- [MessageStatus.WAITING_SESSION, MessageStatus.READY].includes(msg.status))
730
- .count();
750
+ const rows = await getSendQueueMessages(ownerUserId, contactUserId);
751
+ return rows.length;
731
752
  }
732
753
  /**
733
754
  * Send a keep-alive message without adding it to the message history.
@@ -765,34 +786,25 @@ export class MessageService {
765
786
  */
766
787
  async getWaitingMessageCount(contactUserId) {
767
788
  const ownerUserId = this.session.userIdEncoded;
768
- return await this.db.messages
769
- .where('[ownerUserId+contactUserId+status]')
770
- .equals([ownerUserId, contactUserId, MessageStatus.WAITING_SESSION])
771
- .count();
789
+ return getWaitingCount(ownerUserId, contactUserId);
772
790
  }
773
791
  // Mark a message as read. Returns true if the message has been marked as read, false if it was already marked as read or doesn't exist.
774
792
  async markAsRead(id) {
775
- return await this.db.transaction('rw', [this.db.messages, this.db.discussions], async () => {
776
- // Check current message status from DB to avoid race conditions
777
- const message = await this.db.messages.get(id);
778
- if (!message || message.status !== MessageStatus.DELIVERED) {
779
- // Message was already marked as read or doesn't exist
780
- return false;
781
- }
782
- // Update message status
783
- await this.db.messages.update(id, {
784
- status: MessageStatus.READ,
785
- });
786
- // Decrement discussion unread count
787
- await this.db.discussions
788
- .where('[ownerUserId+contactUserId]')
789
- .equals([message.ownerUserId, message.contactUserId])
790
- .modify(discussion => {
791
- if (discussion.unreadCount > 0) {
792
- discussion.unreadCount -= 1;
793
- }
794
- });
795
- return true;
796
- });
793
+ // Check current message status from DB to avoid race conditions
794
+ const row = await getMessageById(id);
795
+ if (!row || row.status !== MessageStatus.DELIVERED) {
796
+ // Message was already marked as read or doesn't exist
797
+ return false;
798
+ }
799
+ const message = rowToMessage(row);
800
+ // Update message status
801
+ await updateMessageById(id, { status: MessageStatus.READ });
802
+ // Atomically decrement discussion unread count
803
+ const discussion = await getDiscussionByOwnerAndContact(message.ownerUserId, message.contactUserId);
804
+ if (discussion) {
805
+ await decrementUnreadCount(discussion.id);
806
+ }
807
+ this.eventEmitter.emit(SdkEventType.MESSAGE_READ, id);
808
+ return true;
797
809
  }
798
810
  }