@massalabs/gossip-sdk 0.0.2-dev.20260417075224 → 0.0.2-dev.20260420074041

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.
@@ -8,6 +8,8 @@ export declare enum SdkEventType {
8
8
  MESSAGE_SENT = "messageSent",
9
9
  MESSAGE_READ = "messageRead",
10
10
  MESSAGE_FAILED = "messageFailed",
11
+ MESSAGE_DELETED = "messageDeleted",
12
+ MESSAGE_UPDATED = "messageUpdated",
11
13
  SESSION_REQUESTED = "sessionRequested",
12
14
  SESSION_CREATED = "sessionCreated",
13
15
  SESSION_RENEWED = "sessionRenewed",
@@ -28,6 +30,12 @@ export type SdkEvents = {
28
30
  message: Message;
29
31
  error: Error;
30
32
  };
33
+ [SdkEventType.MESSAGE_DELETED]: {
34
+ messages: Message[];
35
+ };
36
+ [SdkEventType.MESSAGE_UPDATED]: {
37
+ messages: Message[];
38
+ };
31
39
  [SdkEventType.SESSION_REQUESTED]: {
32
40
  discussion: Discussion;
33
41
  contact: Contact;
@@ -40,7 +48,7 @@ export type SdkEvents = {
40
48
  contactUserId: string;
41
49
  status: SessionStatus;
42
50
  };
43
- [SdkEventType.DISCUSSION_UPDATED]: string;
51
+ [SdkEventType.DISCUSSION_UPDATED]: number;
44
52
  [SdkEventType.MESSAGE_ACKNOWLEDGED]: {
45
53
  contactUserId: string;
46
54
  messageDbId: number;
@@ -8,6 +8,8 @@ export var SdkEventType;
8
8
  SdkEventType["MESSAGE_SENT"] = "messageSent";
9
9
  SdkEventType["MESSAGE_READ"] = "messageRead";
10
10
  SdkEventType["MESSAGE_FAILED"] = "messageFailed";
11
+ SdkEventType["MESSAGE_DELETED"] = "messageDeleted";
12
+ SdkEventType["MESSAGE_UPDATED"] = "messageUpdated";
11
13
  SdkEventType["SESSION_REQUESTED"] = "sessionRequested";
12
14
  SdkEventType["SESSION_CREATED"] = "sessionCreated";
13
15
  SdkEventType["SESSION_RENEWED"] = "sessionRenewed";
@@ -9,10 +9,10 @@ export class ActiveSeekerQueries {
9
9
  });
10
10
  }
11
11
  async replaceAll(seekers) {
12
- await this.conn.withTransaction(async () => {
13
- await this.conn.db.delete(schema.activeSeekers);
12
+ await this.conn.db.transaction(async (tx) => {
13
+ await tx.delete(schema.activeSeekers);
14
14
  if (seekers.length > 0) {
15
- await this.conn.db
15
+ await tx
16
16
  .insert(schema.activeSeekers)
17
17
  .values(seekers.map(seeker => ({ seeker })));
18
18
  }
@@ -1,18 +1,20 @@
1
1
  import * as schema from '../schema/index.js';
2
- import type { DatabaseConnection } from '../sqlite.js';
2
+ import type { DatabaseConnection, GossipSqliteTx } from '../sqlite.js';
3
+ import type { MessageRow } from './messages.js';
3
4
  export type DiscussionRow = typeof schema.discussions.$inferSelect;
4
5
  export type DiscussionInsert = typeof schema.discussions.$inferInsert;
5
6
  export declare class DiscussionQueries {
6
7
  private conn;
7
8
  constructor(conn: DatabaseConnection);
8
- getByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<DiscussionRow | undefined>;
9
+ getByOwnerAndContact(ownerUserId: string, contactUserId: string, tx?: GossipSqliteTx): Promise<DiscussionRow | undefined>;
9
10
  getByOwner(ownerUserId: string): Promise<DiscussionRow[]>;
10
11
  getById(id: number): Promise<DiscussionRow | undefined>;
12
+ getLastTextMessage(contactUserId: string, tx?: GossipSqliteTx): Promise<MessageRow | undefined>;
11
13
  insert(values: DiscussionInsert): Promise<number>;
12
- updateById(id: number, data: Partial<DiscussionInsert>): Promise<void>;
14
+ updateById(id: number, data: Partial<DiscussionInsert>, tx?: GossipSqliteTx): Promise<void>;
13
15
  updateByOwnerAndContact(ownerUserId: string, contactUserId: string, data: Partial<DiscussionInsert>): Promise<void>;
14
16
  deleteById(id: number): Promise<void>;
15
17
  deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
16
- incrementUnreadCount(discussionId: number): Promise<void>;
17
- decrementUnreadCount(discussionId: number): Promise<void>;
18
+ incrementUnreadCount(discussionId: number, tx?: GossipSqliteTx): Promise<void>;
19
+ decrementUnreadCount(discussionId: number, tx?: GossipSqliteTx): Promise<void>;
18
20
  }
@@ -1,5 +1,6 @@
1
1
  import { eq, and, gt, sql } from 'drizzle-orm';
2
2
  import * as schema from '../schema/index.js';
3
+ import { MessageType } from '../db.js';
3
4
  export class DiscussionQueries {
4
5
  constructor(conn) {
5
6
  Object.defineProperty(this, "conn", {
@@ -9,8 +10,8 @@ export class DiscussionQueries {
9
10
  value: conn
10
11
  });
11
12
  }
12
- async getByOwnerAndContact(ownerUserId, contactUserId) {
13
- return this.conn.db
13
+ async getByOwnerAndContact(ownerUserId, contactUserId, tx) {
14
+ return (tx ?? this.conn.db)
14
15
  .select()
15
16
  .from(schema.discussions)
16
17
  .where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.contactUserId, contactUserId)))
@@ -30,12 +31,21 @@ export class DiscussionQueries {
30
31
  .where(eq(schema.discussions.id, id))
31
32
  .get();
32
33
  }
34
+ async getLastTextMessage(contactUserId, tx) {
35
+ return (tx ?? this.conn.db)
36
+ .select()
37
+ .from(schema.messages)
38
+ .where(and(eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.type, MessageType.TEXT)))
39
+ .orderBy(sql `${schema.messages.timestamp} DESC`)
40
+ .limit(1)
41
+ .get();
42
+ }
33
43
  async insert(values) {
34
44
  await this.conn.db.insert(schema.discussions).values(values);
35
45
  return this.conn.getLastInsertRowId();
36
46
  }
37
- async updateById(id, data) {
38
- await this.conn.db
47
+ async updateById(id, data, tx) {
48
+ await (tx ?? this.conn.db)
39
49
  .update(schema.discussions)
40
50
  .set(data)
41
51
  .where(eq(schema.discussions.id, id));
@@ -56,16 +66,16 @@ export class DiscussionQueries {
56
66
  .delete(schema.discussions)
57
67
  .where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.contactUserId, contactUserId)));
58
68
  }
59
- async incrementUnreadCount(discussionId) {
60
- await this.conn.db
69
+ async incrementUnreadCount(discussionId, tx) {
70
+ await (tx ?? this.conn.db)
61
71
  .update(schema.discussions)
62
72
  .set({
63
73
  unreadCount: sql `${schema.discussions.unreadCount} + 1`,
64
74
  })
65
75
  .where(eq(schema.discussions.id, discussionId));
66
76
  }
67
- async decrementUnreadCount(discussionId) {
68
- await this.conn.db
77
+ async decrementUnreadCount(discussionId, tx) {
78
+ await (tx ?? this.conn.db)
69
79
  .update(schema.discussions)
70
80
  .set({
71
81
  unreadCount: sql `MAX(${schema.discussions.unreadCount} - 1, 0)`,
@@ -1,6 +1,6 @@
1
1
  import type { DiscussionRow } from './discussions.js';
2
2
  import * as schema from '../schema/index.js';
3
- import type { DatabaseConnection } from '../sqlite.js';
3
+ import type { DatabaseConnection, GossipSqliteTx } from '../sqlite.js';
4
4
  import { MessageStatus } from '../../db/db.js';
5
5
  export type MessageRow = typeof schema.messages.$inferSelect;
6
6
  export type MessageInsert = typeof schema.messages.$inferInsert;
@@ -14,9 +14,9 @@ export declare class MessageQueries {
14
14
  getReactionsByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
15
15
  getByOwnerAndSeeker(ownerUserId: string, seeker: Uint8Array): Promise<MessageRow | undefined>;
16
16
  findByMessageId(ownerUserId: string, contactUserId: string, messageId: Uint8Array): Promise<MessageRow | undefined>;
17
- insert(values: MessageInsert): Promise<number>;
17
+ insert(values: MessageInsert, tx?: GossipSqliteTx): Promise<number>;
18
18
  batchInsert(values: MessageInsert[]): Promise<void>;
19
- updateById(id: number, data: Partial<MessageInsert>): Promise<void>;
19
+ updateById(id: number, data: Partial<MessageInsert>, tx?: GossipSqliteTx): Promise<void>;
20
20
  deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
21
21
  deleteById(id: number): Promise<void>;
22
22
  deleteReactionsForMessage(ownerUserId: string, contactUserId: string, messageIdBase64: string): Promise<void>;
@@ -74,8 +74,8 @@ export class MessageQueries {
74
74
  .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.messageId, messageId)))
75
75
  .get();
76
76
  }
77
- async insert(values) {
78
- await this.conn.db.insert(schema.messages).values(values);
77
+ async insert(values, tx) {
78
+ await (tx ?? this.conn.db).insert(schema.messages).values(values);
79
79
  return this.conn.getLastInsertRowId();
80
80
  }
81
81
  async batchInsert(values) {
@@ -83,8 +83,8 @@ export class MessageQueries {
83
83
  return;
84
84
  await this.conn.db.insert(schema.messages).values(values);
85
85
  }
86
- async updateById(id, data) {
87
- await this.conn.db
86
+ async updateById(id, data, tx) {
87
+ await (tx ?? this.conn.db)
88
88
  .update(schema.messages)
89
89
  .set(data)
90
90
  .where(eq(schema.messages.id, id));
@@ -14,6 +14,8 @@
14
14
  import { type SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
15
15
  import * as schema from './schema/index.js';
16
16
  export type GossipDatabase = SqliteRemoteDatabase<typeof schema>;
17
+ /** Callback `tx` from `GossipDatabase.transaction()` — pass through query helpers for the same API as `db`. */
18
+ export type GossipSqliteTx = Parameters<Parameters<GossipDatabase['transaction']>[0]>[0];
17
19
  /** Selects the SQLite storage backend. */
18
20
  export type StorageConfig = {
19
21
  type: 'opfs';
@@ -275,7 +275,7 @@ export class DiscussionService {
275
275
  const result = await this.initialize(contact, payload);
276
276
  if (result.success) {
277
277
  await this.refreshService?.stateUpdate();
278
- this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, contact.userId);
278
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, result.data.discussionId);
279
279
  }
280
280
  return result;
281
281
  }
@@ -14,6 +14,7 @@ import { SdkEventEmitter } from '../core/SdkEventEmitter.js';
14
14
  import type { RefreshService } from './refresh.js';
15
15
  import { Queries } from '../db/queries/index.js';
16
16
  import { QueueManager } from '../utils/queue.js';
17
+ import { GossipSqliteTx } from '../db/sqlite.js';
17
18
  /** Options for the simplified sendText method */
18
19
  export interface SendTextOptions {
19
20
  /** Reply to an existing message */
@@ -71,27 +72,9 @@ export declare class MessageService {
71
72
  */
72
73
  private handleDuplicateMessageId;
73
74
  findMessageByMsgId(messageId: Uint8Array, ownerUserId: string, contactUserId?: string): Promise<Message | undefined>;
74
- findMessageBySeeker(seeker: Uint8Array, ownerUserId: string): Promise<Message | undefined>;
75
75
  private acknowledgeMessages;
76
- sendMessage(message: Message): Promise<SendMessageResult>;
77
- /**
78
- * Happy-path send: peer session is Active, so we can encrypt locally,
79
- * fire the network POST, and INSERT the row in parallel. The three
80
- * pieces are independent up until the final `UPDATE → SENT`, which
81
- * needs both the row id (from INSERT) and a successful network ack.
82
- *
83
- * ┌── INSERT (~440 ms) ───────────────┐
84
- * │ ├── UPDATE → SENT (background)
85
- * └── encrypt (~150 ms) → POST (~620 ms)
86
- * │
87
- * └── emit MESSAGE_SENT
88
- *
89
- * Returns `null` if the fast path can't run (encrypt declined, network
90
- * threw, etc.) so the caller can fall back to the slow path.
91
- */
92
- private sendMessageFastPath;
76
+ sendMessage(message: Message, parentTx?: GossipSqliteTx): Promise<SendMessageResult>;
93
77
  private serializeMessage;
94
- resendMessages(messages: Map<string, Message[]>): Promise<void>;
95
78
  /**
96
79
  * Process the send queue for a single contact.
97
80
  * Handles WAITING_SESSION -> READY encryption and READY -> SENT delivery.
@@ -101,10 +84,6 @@ export declare class MessageService {
101
84
  * Count pending outgoing messages for a contact (WAITING_SESSION/READY).
102
85
  */
103
86
  getPendingSendCount(contactUserId: string): Promise<number>;
104
- /**
105
- * Get count of messages waiting for session with a specific contact.
106
- */
107
- getWaitingMessageCount(contactUserId: string): Promise<number>;
108
87
  /** Get a message by its database ID */
109
88
  get(id: number): Promise<Message | undefined>;
110
89
  /** Get all messages for a contact (using session owner).
@@ -124,6 +103,7 @@ export declare class MessageService {
124
103
  sendText(contactUserId: string, text: string, options?: SendTextOptions): Promise<SendMessageResult>;
125
104
  /** Fetch and decrypt messages from the protocol (alias) */
126
105
  fetch(): Promise<MessageResult>;
106
+ private PerformDeleteMessage;
127
107
  /**
128
108
  * Delete a message by its database ID (outgoing or incoming in 1-to-1).
129
109
  * Marks the local message as deleted and enqueues a delete control message
@@ -133,6 +113,7 @@ export declare class MessageService {
133
113
  */
134
114
  deleteMessage(id: number): Promise<boolean>;
135
115
  sendReaction(contactUserId: string, emoji: string, originalMsgId: Uint8Array): Promise<SendMessageResult>;
116
+ private performEditMessage;
136
117
  /**
137
118
  * Edit an outgoing message by its database ID.
138
119
  * Updates the local content (preserving timestamp) and enqueues an edit
@@ -8,11 +8,14 @@ import { MessageDirection, MessageStatus, MessageType, MESSAGE_ID_SIZE, } from '
8
8
  import { decodeUserId, encodeUserId } from '../utils/userId.js';
9
9
  import { SessionStatus } from '../wasm/bindings.js';
10
10
  import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, serializeDeleteMessage, serializeEditMessage, serializeReactionMessage, serializeRetentionPolicyMessage, deserializeMessage, } from '../utils/messageSerialization.js';
11
+ import * as schema from '../db/schema/index.js';
11
12
  import { encodeToBase64, decodeFromBase64 } from '../utils/base64.js';
12
13
  import { sessionStatusToString } from '../wasm/session.js';
13
14
  import { Logger } from '../utils/logs.js';
14
15
  import { defaultSdkConfig } from '../config/sdk.js';
15
16
  import { SdkEventType } from '../core/SdkEventEmitter.js';
17
+ import { and, eq, sql } from 'drizzle-orm';
18
+ import { POST_MESSAGE_TYPES } from '../utils/message.js';
16
19
  // ---------------------------------------------------------------------------
17
20
  // JSON serialization helpers for message fields stored as text in SQLite
18
21
  // ---------------------------------------------------------------------------
@@ -309,8 +312,9 @@ export class MessageService {
309
312
  /**
310
313
  * Add a message to SQLite and update the corresponding discussion.
311
314
  */
312
- async addMessageAndUpdateDiscussion(message) {
313
- return this.queries.conn.withTransaction(async () => {
315
+ async addMessageAndUpdateDiscussion(message, parentTx) {
316
+ const db = parentTx ?? this.queries.conn.db;
317
+ const result = await db.transaction(async (tx) => {
314
318
  const messageId = await this.queries.messages.insert({
315
319
  messageId: message.messageId,
316
320
  ownerUserId: message.ownerUserId,
@@ -330,24 +334,26 @@ export class MessageService {
330
334
  reactionOf: serializeReactionOf(message.reactionOf),
331
335
  encryptedMessage: message.encryptedMessage,
332
336
  whenToSend: message.whenToSend,
333
- });
334
- const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
335
- if (discussion &&
336
- message.type !== MessageType.KEEP_ALIVE &&
337
- message.type !== MessageType.REACTION &&
338
- message.type !== MessageType.RETENTION_POLICY) {
337
+ }, tx);
338
+ const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, tx);
339
+ if (discussion && POST_MESSAGE_TYPES.includes(message.type)) {
339
340
  await this.queries.discussions.updateById(discussion.id, {
340
341
  lastMessageId: messageId,
341
342
  lastMessageContent: message.content,
342
343
  lastMessageTimestamp: message.timestamp,
343
344
  updatedAt: new Date(),
344
- });
345
+ }, tx);
345
346
  if (message.direction === MessageDirection.INCOMING) {
346
- await this.queries.discussions.incrementUnreadCount(discussion.id);
347
+ await this.queries.discussions.incrementUnreadCount(discussion.id, tx);
347
348
  }
349
+ return { messageId, updatedDiscussionId: discussion?.id };
348
350
  }
349
- return messageId;
351
+ return { messageId, updatedDiscussionId: null };
350
352
  });
353
+ if (result.updatedDiscussionId) {
354
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, result.updatedDiscussionId);
355
+ }
356
+ return result.messageId;
351
357
  }
352
358
  async decryptMessages(encrypted) {
353
359
  const log = logger.forMethod('decryptMessages');
@@ -419,34 +425,13 @@ export class MessageService {
419
425
  });
420
426
  continue;
421
427
  }
422
- if (target.type === MessageType.REACTION) {
423
- // Reaction delete: hard-delete the row, not "[Message deleted]"
424
- await this.queries.messages.deleteById(target.id);
425
- this.emitMessageReceived({
426
- ...target,
427
- type: MessageType.DELETED,
428
- });
429
- }
430
- else {
431
- // Regular message delete: mark as deleted
432
- this.emitMessageReceived({
433
- ...target,
434
- content: '[Message deleted]',
435
- type: MessageType.DELETED,
436
- });
437
- const targetId = target.id;
438
- const deleteOriginalMsgId = message.deleteOf.originalMsgId;
439
- await this.queries.conn.withTransaction(async () => {
440
- await this.queries.messages.updateById(targetId, {
441
- content: '[Message deleted]',
442
- type: MessageType.DELETED,
443
- });
444
- await this.queries.messages.deleteReactionsForMessage(ownerUserId, target.contactUserId, encodeToBase64(deleteOriginalMsgId));
445
- });
428
+ const resDb = await this.PerformDeleteMessage(target);
429
+ if (!resDb.success) {
430
+ throw new Error(resDb.error?.message ?? 'Failed to delete message from db');
446
431
  }
447
432
  continue;
448
433
  }
449
- // Handle edit control messages by updating the referenced message in-place
434
+ // Handle EDIT control messages by updating the referenced message in-place
450
435
  if (message.editOf?.originalMsgId) {
451
436
  const target = await this.findMessageByMsgId(message.editOf.originalMsgId, ownerUserId, message.senderId);
452
437
  if (!target || !target.id) {
@@ -465,10 +450,10 @@ export class MessageService {
465
450
  content: message.content,
466
451
  metadata: mergedMetadata,
467
452
  });
468
- await this.queries.messages.updateById(target.id, {
469
- content: message.content,
470
- metadata: serializeMetadata(mergedMetadata),
471
- });
453
+ const res = await this.performEditMessage(message.content, target, mergedMetadata);
454
+ if (!res.success) {
455
+ throw new Error(res.error?.message ?? 'Failed to edit message in db');
456
+ }
472
457
  // Do not insert a new message row for edit control messages
473
458
  continue;
474
459
  }
@@ -482,7 +467,15 @@ export class MessageService {
482
467
  messageRetentionDuration: duration,
483
468
  retentionPolicySetAt: duration ? Date.now() : null,
484
469
  });
485
- this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, message.senderId);
470
+ const discussion = await this.queries.discussions.getByOwnerAndContact(ownerUserId, message.senderId);
471
+ if (!discussion) {
472
+ this.eventEmitter.emit(SdkEventType.ERROR, {
473
+ error: new Error('could no retrieve discussion after updating retention policy'),
474
+ context: 'storeDecryptedMessages',
475
+ });
476
+ continue;
477
+ }
478
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussion.id);
486
479
  // Do not insert a new message row for retention policy control messages
487
480
  continue;
488
481
  }
@@ -497,8 +490,7 @@ export class MessageService {
497
490
  });
498
491
  continue;
499
492
  }
500
- // Emit before DB write so UI updates instantly
501
- this.emitMessageReceived({
493
+ const id = await this.queries.messages.insert({
502
494
  messageId: message.messageId,
503
495
  ownerUserId,
504
496
  contactUserId: discussion.contactUserId,
@@ -507,9 +499,11 @@ export class MessageService {
507
499
  direction: MessageDirection.INCOMING,
508
500
  status: MessageStatus.DELIVERED,
509
501
  timestamp: message.sentAt,
510
- reactionOf: message.reactionOf,
502
+ metadata: serializeMetadata({}),
503
+ reactionOf: serializeReactionOf(message.reactionOf),
511
504
  });
512
- const id = await this.queries.messages.insert({
505
+ this.emitMessageReceived({
506
+ id,
513
507
  messageId: message.messageId,
514
508
  ownerUserId,
515
509
  contactUserId: discussion.contactUserId,
@@ -518,8 +512,7 @@ export class MessageService {
518
512
  direction: MessageDirection.INCOMING,
519
513
  status: MessageStatus.DELIVERED,
520
514
  timestamp: message.sentAt,
521
- metadata: serializeMetadata({}),
522
- reactionOf: serializeReactionOf(message.reactionOf),
515
+ reactionOf: message.reactionOf,
523
516
  });
524
517
  storedIds.push(id);
525
518
  // Do not update discussion lastMessageContent for reactions
@@ -536,7 +529,7 @@ export class MessageService {
536
529
  // If received msg has same messageId as a previously received msg
537
530
  const isDuplicate = await this.handleDuplicateMessageId(message, ownerUserId);
538
531
  if (isDuplicate) {
539
- log.info('Duplicate message received, skipping', {
532
+ log.info('Duplicate message received, skip ping', {
540
533
  senderId: message.senderId,
541
534
  preview: message.content.slice(0, 30),
542
535
  });
@@ -562,8 +555,6 @@ export class MessageService {
562
555
  replyTo: message.replyTo,
563
556
  forwardOf: message.forwardOf,
564
557
  };
565
- // Emit before DB write — UI shows message instantly
566
- this.emitMessageReceived(incomingMsg);
567
558
  const id = await this.queries.messages.insert({
568
559
  messageId: message.messageId,
569
560
  ownerUserId,
@@ -590,6 +581,7 @@ export class MessageService {
590
581
  storedIds.push(id);
591
582
  // Re-emit with DB id so the store patches the optimistic message
592
583
  this.emitMessageReceived({ ...incomingMsg, id });
584
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussion.id);
593
585
  }
594
586
  return storedIds;
595
587
  }
@@ -613,10 +605,6 @@ export class MessageService {
613
605
  const row = await this.queries.messages.findByMessageId(ownerUserId, contactUserId, messageId);
614
606
  return row ? rowToMessage(row) : undefined;
615
607
  }
616
- async findMessageBySeeker(seeker, ownerUserId) {
617
- const row = await this.queries.messages.getByOwnerAndSeeker(ownerUserId, seeker);
618
- return row ? rowToMessage(row) : undefined;
619
- }
620
608
  async acknowledgeMessages(seekers, userId) {
621
609
  if (seekers.size === 0)
622
610
  return;
@@ -645,7 +633,7 @@ export class MessageService {
645
633
  .info(`acknowledged ${toUpdate.length} messages`);
646
634
  }
647
635
  }
648
- async sendMessage(message) {
636
+ async sendMessage(message, parentTx) {
649
637
  const log = logger.forMethod('sendMessage');
650
638
  log.info('queueing message', {
651
639
  messageType: message.type,
@@ -658,7 +646,7 @@ export class MessageService {
658
646
  };
659
647
  }
660
648
  // Look up discussion
661
- const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
649
+ const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, parentTx);
662
650
  if (!discussion) {
663
651
  return { success: false, error: 'Discussion not found' };
664
652
  }
@@ -671,29 +659,12 @@ export class MessageService {
671
659
  : undefined;
672
660
  message.messageId = randomMessageId;
673
661
  }
674
- // Fast path: if the peer's session is already Active we can encrypt
675
- // and ship the message in parallel with the local INSERT instead of
676
- // waiting for `addMessageAndUpdateDiscussion` to commit before
677
- // `processSendQueueForContact` even starts. The user-perceived
678
- // latency drops from `INSERT + encrypt + POST` (sequential) to
679
- // `max(INSERT, encrypt + POST)` — ~440 ms saved on prod where the
680
- // network round-trip dominates.
681
- const sessionStatus = this.session.peerSessionStatus(peerId);
682
- if (sessionStatus === SessionStatus.Active) {
683
- const fastPathResult = await this.sendMessageFastPath(message, peerId);
684
- if (fastPathResult) {
685
- return fastPathResult;
686
- }
687
- // Fast path bailed (encrypt returned null, etc.) — fall back to
688
- // the slow path below.
689
- }
690
- // Slow path: INSERT first, let stateUpdate handle the encrypt + send.
691
- let messageId;
662
+ let messageIdDb;
692
663
  try {
693
- messageId = await this.addMessageAndUpdateDiscussion({
664
+ messageIdDb = await this.addMessageAndUpdateDiscussion({
694
665
  ...message,
695
666
  status: MessageStatus.WAITING_SESSION,
696
- });
667
+ }, parentTx);
697
668
  }
698
669
  catch (error) {
699
670
  return {
@@ -701,186 +672,21 @@ export class MessageService {
701
672
  error: 'Failed to add message to database, got error: ' + error,
702
673
  };
703
674
  }
704
- const queuedMessage = {
705
- ...message,
706
- id: messageId,
707
- status: MessageStatus.WAITING_SESSION,
708
- };
709
675
  /*
710
- Trigger a state update to send the new message.
711
- If the stateUpdate function is already running, it will be skipped.
676
+ Trigger a sending queue state update for contact in order to send the new message.
677
+ If the processSendQueueForContact function is already running, it will be skipped.
712
678
  */
713
- await this.refreshService?.stateUpdate();
714
- return {
715
- success: true,
716
- message: queuedMessage,
717
- };
718
- }
719
- /**
720
- * Happy-path send: peer session is Active, so we can encrypt locally,
721
- * fire the network POST, and INSERT the row in parallel. The three
722
- * pieces are independent up until the final `UPDATE → SENT`, which
723
- * needs both the row id (from INSERT) and a successful network ack.
724
- *
725
- * ┌── INSERT (~440 ms) ───────────────┐
726
- * │ ├── UPDATE → SENT (background)
727
- * └── encrypt (~150 ms) → POST (~620 ms)
728
- * │
729
- * └── emit MESSAGE_SENT
730
- *
731
- * Returns `null` if the fast path can't run (encrypt declined, network
732
- * threw, etc.) so the caller can fall back to the slow path.
733
- */
734
- async sendMessageFastPath(message, peerId) {
735
- const log = logger.forMethod('sendMessageFastPath');
736
- // 1. Serialize synchronously — needed before encrypt.
737
- const serializeResult = await this.serializeMessage(message);
738
- if (!serializeResult.success) {
739
- log.error('failed to serialize message for fast path', {
740
- error: serializeResult.error,
741
- });
742
- return null;
743
- }
744
- const serializedContent = serializeResult.data;
745
- // 2. Kick off the local INSERT and the encrypt in parallel.
746
- // encrypt is fast (~150 ms with rayon) and doesn't depend on
747
- // the row id; INSERT is slow (~440 ms) and only the row id is
748
- // later needed for the SENT update.
749
- const insertPromise = this.addMessageAndUpdateDiscussion({
750
- ...message,
751
- status: MessageStatus.WAITING_SESSION,
752
- })
753
- .then(id => {
754
- this.inFlightFastPath.add(id);
755
- return id;
756
- })
757
- .catch(error => {
758
- log.error('addMessageAndUpdateDiscussion failed in fast path', {
759
- error,
760
- });
761
- return null;
762
- });
763
- let sendOutput;
764
- try {
765
- sendOutput = await this.session.sendMessage(peerId, serializedContent);
766
- }
767
- catch (error) {
768
- log.error('session.sendMessage threw in fast path, falling back', {
769
- error,
770
- });
771
- sendOutput = undefined;
772
- }
773
- if (!sendOutput) {
774
- // Session became inactive between status check and encrypt.
775
- // Wait for the INSERT to land then bail out so the slow path
776
- // can take over.
777
- const messageId = await insertPromise;
778
- if (messageId === null) {
779
- return {
780
- success: false,
781
- error: 'Failed to add message to database',
782
- };
783
- }
784
- log.info('encrypt returned null in fast path, falling back to slow path', { messageId });
785
- this.inFlightFastPath.delete(messageId);
786
- void this.refreshService?.stateUpdate();
787
- return {
788
- success: true,
789
- message: {
790
- ...message,
791
- id: messageId,
792
- status: MessageStatus.WAITING_SESSION,
793
- },
794
- };
795
- }
796
- // 3. Encrypt succeeded. Network POST runs in parallel with the
797
- // still-in-flight INSERT.
798
- const networkPromise = this.messageProtocol
799
- .sendMessage({
800
- seeker: sendOutput.seeker,
801
- ciphertext: sendOutput.data,
802
- })
803
- .then(() => true)
804
- .catch(error => {
805
- log.error('network send failed in fast path', { error });
806
- return false;
807
- });
808
- const [messageId, networkOk] = await Promise.all([
809
- insertPromise,
810
- networkPromise,
811
- ]);
812
- if (messageId === null) {
679
+ await this.processSendQueueForContact(message.contactUserId);
680
+ const messageDb = await this.queries.messages.getById(messageIdDb);
681
+ if (!messageDb) {
813
682
  return {
814
683
  success: false,
815
- error: 'Failed to add message to database',
684
+ error: 'Could not retrieve message after adding it to the database',
816
685
  };
817
686
  }
818
- if (!networkOk) {
819
- // Network failed: persist READY so the next retry doesn't
820
- // re-encrypt, and let stateUpdate pick it up.
821
- this.inFlightFastPath.delete(messageId);
822
- await this.queries.messages.updateById(messageId, {
823
- status: MessageStatus.READY,
824
- encryptedMessage: sendOutput.data,
825
- seeker: sendOutput.seeker,
826
- whenToSend: new Date(Date.now() + this.config.messages.retryDelayMs),
827
- serializedContent,
828
- });
829
- void this.refreshService?.stateUpdate();
830
- return {
831
- success: true,
832
- message: {
833
- ...message,
834
- id: messageId,
835
- status: MessageStatus.READY,
836
- },
837
- };
838
- }
839
- // 4. Both succeeded. Race-check then UPDATE → SENT.
840
- const latestRow = await this.queries.messages.getById(messageId);
841
- if (!latestRow || latestRow.status !== MessageStatus.WAITING_SESSION) {
842
- log.debug('message gone or status changed during fast-path send, skipping SENT update', { messageId, currentStatus: latestRow?.status });
843
- this.inFlightFastPath.delete(messageId);
844
- return {
845
- success: true,
846
- message: {
847
- ...message,
848
- id: messageId,
849
- status: MessageStatus.SENT,
850
- },
851
- };
852
- }
853
- await this.queries.messages.updateById(messageId, {
854
- status: MessageStatus.SENT,
855
- seeker: sendOutput.seeker,
856
- encryptedMessage: null,
857
- serializedContent: null,
858
- whenToSend: null,
859
- });
860
- this.inFlightFastPath.delete(messageId);
861
- const isControlMessage = !!(message.deleteOf || message.editOf);
862
- if (!isControlMessage) {
863
- try {
864
- this.eventEmitter.emit(SdkEventType.MESSAGE_SENT, {
865
- ...message,
866
- id: messageId,
867
- status: MessageStatus.SENT,
868
- });
869
- }
870
- catch (error) {
871
- log.error('failed to emit message sent event from fast path', {
872
- messageId,
873
- error,
874
- });
875
- }
876
- }
877
687
  return {
878
688
  success: true,
879
- message: {
880
- ...message,
881
- id: messageId,
882
- status: MessageStatus.SENT,
883
- },
689
+ message: rowToMessage(messageDb),
884
690
  };
885
691
  }
886
692
  async serializeMessage(message) {
@@ -982,28 +788,6 @@ export class MessageService {
982
788
  };
983
789
  }
984
790
  }
985
- async resendMessages(messages) {
986
- const log = logger.forMethod('resendMessages');
987
- let totalProcessed = 0;
988
- for (const [contactId, retryMessages] of messages.entries()) {
989
- totalProcessed += retryMessages.length;
990
- for (const msg of retryMessages) {
991
- if (!msg.id)
992
- continue;
993
- await this.queries.messages.updateById(msg.id, {
994
- status: MessageStatus.WAITING_SESSION,
995
- encryptedMessage: null,
996
- seeker: null,
997
- whenToSend: null,
998
- });
999
- }
1000
- await this.processSendQueueForContact(contactId);
1001
- }
1002
- log.info('resend completed', {
1003
- contacts: messages.size,
1004
- messagesProcessed: totalProcessed,
1005
- });
1006
- }
1007
791
  /**
1008
792
  * Process the send queue for a single contact.
1009
793
  * Handles WAITING_SESSION -> READY encryption and READY -> SENT delivery.
@@ -1275,13 +1059,6 @@ export class MessageService {
1275
1059
  const rows = await this.queries.messages.getSendQueue(ownerUserId, contactUserId);
1276
1060
  return rows.length;
1277
1061
  }
1278
- /**
1279
- * Get count of messages waiting for session with a specific contact.
1280
- */
1281
- async getWaitingMessageCount(contactUserId) {
1282
- const ownerUserId = this.session.userIdEncoded;
1283
- return this.queries.messages.getWaitingCount(ownerUserId, contactUserId);
1284
- }
1285
1062
  // ─────────────────────────────────────────────────────────────────
1286
1063
  // Consumer-facing convenience methods
1287
1064
  // ─────────────────────────────────────────────────────────────────
@@ -1338,6 +1115,114 @@ export class MessageService {
1338
1115
  async fetch() {
1339
1116
  return this.fetchMessages();
1340
1117
  }
1118
+ async PerformDeleteMessage(message, tx) {
1119
+ if (!message.id) {
1120
+ return { success: false, error: new Error('Message ID is required') };
1121
+ }
1122
+ const db = tx ?? this.queries.conn.db;
1123
+ if (message.type === MessageType.REACTION) {
1124
+ // Reaction delete: hard-delete the row, not "[Message deleted]"
1125
+ try {
1126
+ await this.queries.messages.deleteById(message.id);
1127
+ this.eventEmitter.emit(SdkEventType.MESSAGE_DELETED, {
1128
+ messages: [message],
1129
+ });
1130
+ return { success: true, data: null };
1131
+ }
1132
+ catch (error) {
1133
+ return {
1134
+ success: false,
1135
+ error: error instanceof Error
1136
+ ? error
1137
+ : new Error('Unknown error occurred while deleting reaction message'),
1138
+ };
1139
+ }
1140
+ }
1141
+ let deletedMessages = [];
1142
+ let updatedMessages = [];
1143
+ let discussionId;
1144
+ try {
1145
+ // Run all operations inside an explicit transaction for atomicity
1146
+ await db.transaction(async (trx) => {
1147
+ const discussion = await trx
1148
+ .select()
1149
+ .from(schema.discussions)
1150
+ .where(and(eq(schema.discussions.ownerUserId, message.ownerUserId), eq(schema.discussions.contactUserId, message.contactUserId)))
1151
+ .get();
1152
+ if (!discussion) {
1153
+ throw new Error('Discussion not found');
1154
+ }
1155
+ // delete the message : MessageType.DELETED '[Message deleted]' in db
1156
+ await this.queries.messages.updateById(message.id, // message.id is guaranteed to be not null because we checked it above
1157
+ {
1158
+ content: '[Message deleted]',
1159
+ type: MessageType.DELETED,
1160
+ }, trx);
1161
+ if (POST_MESSAGE_TYPES.includes(message.type)) {
1162
+ // If the message to delete is the last text message in the discussion, update the discussion to the previous last text message
1163
+ if (discussion.lastMessageId === message.id) {
1164
+ const lastMessage = await this.queries.discussions.getLastTextMessage(message.contactUserId, trx);
1165
+ await this.queries.discussions.updateById(discussion.id, {
1166
+ lastMessageId: lastMessage?.id ?? null,
1167
+ lastMessageContent: lastMessage?.content ?? null,
1168
+ lastMessageTimestamp: lastMessage?.timestamp ?? null,
1169
+ updatedAt: new Date(),
1170
+ }, trx);
1171
+ discussionId = discussion.id;
1172
+ }
1173
+ // If deleted message is not read yet, decrement the discussion unread count
1174
+ if (message.status !== MessageStatus.READ &&
1175
+ message.direction === MessageDirection.INCOMING) {
1176
+ await this.queries.discussions.decrementUnreadCount(discussion.id, trx);
1177
+ discussionId = discussion.id;
1178
+ }
1179
+ // Delete all REACTION messages for this contact referencing this message
1180
+ const deletedReactionMessages = await trx
1181
+ .delete(schema.messages)
1182
+ .where(and(eq(schema.messages.contactUserId, message.contactUserId), eq(schema.messages.type, MessageType.REACTION), sql `json_extract(${schema.messages.reactionOf}, '$.originalMsgId') = ${encodeToBase64(message.messageId)}`))
1183
+ .returning();
1184
+ deletedMessages = deletedReactionMessages.map(rowToMessage);
1185
+ // Also update all messages REPLYING to this message by setting their replyTo to null
1186
+ const updatedMessagesDb = await trx
1187
+ .update(schema.messages)
1188
+ .set({ replyTo: null })
1189
+ .where(and(eq(schema.messages.contactUserId, message.contactUserId), sql `json_extract(${schema.messages.replyTo}, '$.originalMsgId') = ${encodeToBase64(message.messageId)}`))
1190
+ .returning();
1191
+ updatedMessages = updatedMessagesDb.map(row => rowToMessage(row));
1192
+ }
1193
+ });
1194
+ }
1195
+ catch (error) {
1196
+ return {
1197
+ success: false,
1198
+ error: error instanceof Error ? error : new Error(String(error)),
1199
+ };
1200
+ }
1201
+ // function to be called after the db transaction is committed.
1202
+ // Send events only when we are sure corresponding operation are saved in db
1203
+ const postDbCommit = () => {
1204
+ this.eventEmitter.emit(SdkEventType.MESSAGE_DELETED, {
1205
+ messages: [message, ...deletedMessages],
1206
+ });
1207
+ if (updatedMessages.length > 0) {
1208
+ this.eventEmitter.emit(SdkEventType.MESSAGE_UPDATED, {
1209
+ messages: updatedMessages,
1210
+ });
1211
+ }
1212
+ if (discussionId) {
1213
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionId);
1214
+ }
1215
+ };
1216
+ if (!tx) {
1217
+ // if we are not in a db transaction, we can just emit the event and return
1218
+ postDbCommit();
1219
+ return { success: true, data: null };
1220
+ }
1221
+ else {
1222
+ // if we are in a db transaction, we need to return a function that will be called after the transaction is committed
1223
+ return { success: true, data: postDbCommit };
1224
+ }
1225
+ }
1341
1226
  /**
1342
1227
  * Delete a message by its database ID (outgoing or incoming in 1-to-1).
1343
1228
  * Marks the local message as deleted and enqueues a delete control message
@@ -1352,28 +1237,33 @@ export class MessageService {
1352
1237
  if (!row.messageId)
1353
1238
  throw new Error('Cannot delete a message that has no messageId');
1354
1239
  const ownerUserId = this.session.userIdEncoded;
1355
- const messageId = row.messageId;
1356
- await this.queries.conn.withTransaction(async () => {
1357
- await this.queries.messages.updateById(id, {
1358
- content: '[Message deleted]',
1240
+ const callbackAfterDbCommit = await this.queries.conn.db.transaction(async (tx) => {
1241
+ const res = await this.PerformDeleteMessage(rowToMessage(row), tx);
1242
+ if (!res.success) {
1243
+ tx.rollback(); // if deleting the message from the db fails, rollback the transaction
1244
+ throw new Error(res.error?.message ?? 'Failed to delete message from db');
1245
+ }
1246
+ // Send the delete control message to the peer
1247
+ const controlMessage = {
1248
+ ownerUserId,
1249
+ contactUserId: row.contactUserId,
1250
+ content: '',
1359
1251
  type: MessageType.DELETED,
1360
- });
1361
- await this.queries.messages.deleteReactionsForMessage(ownerUserId, row.contactUserId, encodeToBase64(messageId));
1252
+ direction: MessageDirection.OUTGOING,
1253
+ status: MessageStatus.WAITING_SESSION,
1254
+ timestamp: new Date(),
1255
+ deleteOf: { originalMsgId: row.messageId }, // row.messageId was previously verified to be not null
1256
+ };
1257
+ const result = await this.sendMessage(controlMessage, tx);
1258
+ if (!result.success) {
1259
+ tx.rollback(); // if sending the delete control message fails, rollback the transaction
1260
+ throw new Error(result.error ?? 'Failed to enqueue delete message');
1261
+ }
1262
+ return res.data;
1362
1263
  });
1363
- const controlMessage = {
1364
- ownerUserId,
1365
- contactUserId: row.contactUserId,
1366
- content: '',
1367
- type: MessageType.DELETED,
1368
- direction: MessageDirection.OUTGOING,
1369
- status: MessageStatus.WAITING_SESSION,
1370
- timestamp: new Date(),
1371
- deleteOf: { originalMsgId: row.messageId },
1372
- };
1373
- const result = await this.send(controlMessage);
1374
- if (!result.success)
1375
- throw new Error(result.error ?? 'Failed to enqueue delete message');
1376
- await this.refreshService?.stateUpdate();
1264
+ if (callbackAfterDbCommit) {
1265
+ callbackAfterDbCommit();
1266
+ }
1377
1267
  return true;
1378
1268
  }
1379
1269
  async sendReaction(contactUserId, emoji, originalMsgId) {
@@ -1388,9 +1278,53 @@ export class MessageService {
1388
1278
  reactionOf: { originalMsgId },
1389
1279
  };
1390
1280
  const result = await this.send(message);
1391
- await this.refreshService?.stateUpdate();
1392
1281
  return result;
1393
1282
  }
1283
+ async performEditMessage(newContent, originalMsg, metadata, tx) {
1284
+ if (!originalMsg.id) {
1285
+ return { success: false, error: new Error('Message ID is required') };
1286
+ }
1287
+ const db = tx ?? this.queries.conn.db;
1288
+ let discussionUpdatedId;
1289
+ try {
1290
+ await db
1291
+ .update(schema.messages)
1292
+ .set({ content: newContent, metadata: serializeMetadata(metadata) })
1293
+ .where(eq(schema.messages.id, originalMsg.id))
1294
+ .returning();
1295
+ const discussion = await this.queries.discussions.getByOwnerAndContact(originalMsg.ownerUserId, originalMsg.contactUserId, tx);
1296
+ if (!discussion) {
1297
+ return { success: false, error: new Error('Discussion not found') };
1298
+ }
1299
+ if (discussion.lastMessageId === originalMsg.id) {
1300
+ await this.queries.discussions.updateById(discussion.id, {
1301
+ lastMessageContent: newContent,
1302
+ updatedAt: new Date(),
1303
+ }, tx);
1304
+ discussionUpdatedId = discussion.id;
1305
+ }
1306
+ }
1307
+ catch (error) {
1308
+ return {
1309
+ success: false,
1310
+ error: error instanceof Error ? error : new Error(String(error)),
1311
+ };
1312
+ }
1313
+ const postDbCommit = () => {
1314
+ if (discussionUpdatedId) {
1315
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionUpdatedId);
1316
+ }
1317
+ };
1318
+ if (!tx) {
1319
+ // if we are not in a db transaction, we can just emit the event and return
1320
+ postDbCommit();
1321
+ return { success: true, data: null };
1322
+ }
1323
+ else {
1324
+ // if we are in a db transaction, we need to return a function that will be called after the transaction is committed
1325
+ return { success: true, data: postDbCommit };
1326
+ }
1327
+ }
1394
1328
  /**
1395
1329
  * Edit an outgoing message by its database ID.
1396
1330
  * Updates the local content (preserving timestamp) and enqueues an edit
@@ -1407,25 +1341,33 @@ export class MessageService {
1407
1341
  const ownerUserId = this.session.userIdEncoded;
1408
1342
  const existingMetadata = deserializeMetadata(row.metadata) ?? {};
1409
1343
  const mergedMetadata = { ...existingMetadata, edited: true };
1410
- await this.queries.messages.updateById(id, {
1411
- content: newContent,
1412
- metadata: serializeMetadata(mergedMetadata),
1344
+ const callbackAfterDbCommit = await this.queries.conn.db.transaction(async (tx) => {
1345
+ const res = await this.performEditMessage(newContent, rowToMessage(row), mergedMetadata, tx);
1346
+ if (!res.success) {
1347
+ tx.rollback();
1348
+ throw new Error(res.error?.message ?? 'Failed to edit message in db');
1349
+ }
1350
+ const controlMessage = {
1351
+ ownerUserId,
1352
+ contactUserId: row.contactUserId,
1353
+ content: newContent,
1354
+ type: MessageType.TEXT,
1355
+ direction: MessageDirection.OUTGOING,
1356
+ status: MessageStatus.WAITING_SESSION,
1357
+ timestamp: new Date(),
1358
+ editOf: { originalMsgId: row.messageId }, // row.messageId was previously verified to be not null
1359
+ metadata: { control: 'edit' },
1360
+ };
1361
+ const result = await this.sendMessage(controlMessage, tx);
1362
+ if (!result.success) {
1363
+ tx.rollback();
1364
+ throw new Error(result.error ?? 'Failed to enqueue edit message');
1365
+ }
1366
+ return res.data;
1413
1367
  });
1414
- const controlMessage = {
1415
- ownerUserId,
1416
- contactUserId: row.contactUserId,
1417
- content: newContent,
1418
- type: MessageType.TEXT,
1419
- direction: MessageDirection.OUTGOING,
1420
- status: MessageStatus.WAITING_SESSION,
1421
- timestamp: new Date(),
1422
- editOf: { originalMsgId: row.messageId },
1423
- metadata: { control: 'edit' },
1424
- };
1425
- const result = await this.send(controlMessage);
1426
- if (!result.success)
1427
- throw new Error(result.error ?? 'Failed to enqueue edit message');
1428
- await this.refreshService?.stateUpdate();
1368
+ if (callbackAfterDbCommit) {
1369
+ callbackAfterDbCommit();
1370
+ }
1429
1371
  return true;
1430
1372
  }
1431
1373
  /**
@@ -1449,14 +1391,22 @@ export class MessageService {
1449
1391
  return false;
1450
1392
  }
1451
1393
  const message = rowToMessage(row);
1452
- // Update message status
1453
- await this.queries.messages.updateById(id, { status: MessageStatus.READ });
1454
- // Atomically decrement discussion unread count
1455
- const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
1456
- if (discussion) {
1457
- await this.queries.discussions.decrementUnreadCount(discussion.id);
1458
- }
1394
+ // Perform message status update and unread count decrement atomically in a transaction
1395
+ const discussionId = await this.queries.conn.db.transaction(async (tx) => {
1396
+ // Update message status
1397
+ await this.queries.messages.updateById(id, { status: MessageStatus.READ }, tx);
1398
+ // Atomically decrement discussion unread count
1399
+ const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, tx);
1400
+ if (!discussion || !discussion.id) {
1401
+ throw new Error('Discussion not found');
1402
+ }
1403
+ if (discussion) {
1404
+ await this.queries.discussions.decrementUnreadCount(discussion.id, tx);
1405
+ }
1406
+ return discussion.id;
1407
+ });
1459
1408
  this.eventEmitter.emit(SdkEventType.MESSAGE_READ, id);
1409
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionId);
1460
1410
  return true;
1461
1411
  }
1462
1412
  }
@@ -0,0 +1,2 @@
1
+ import { MessageType } from '../db/db.js';
2
+ export declare const POST_MESSAGE_TYPES: MessageType[];
@@ -0,0 +1,9 @@
1
+ import { MessageType } from '../db/db.js';
2
+ /*Message that add a new post in the discussion */
3
+ export const POST_MESSAGE_TYPES = [
4
+ MessageType.TEXT,
5
+ MessageType.IMAGE,
6
+ MessageType.FILE,
7
+ MessageType.AUDIO,
8
+ MessageType.VIDEO,
9
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/gossip-sdk",
3
- "version": "0.0.2-dev.20260417075224",
3
+ "version": "0.0.2-dev.20260420074041",
4
4
  "description": "Gossip SDK for automation, chatbot, and integration use cases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",