@massalabs/gossip-sdk 0.0.2-dev.20260323092903 → 0.0.2-dev.20260324105033

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.
@@ -16,6 +16,7 @@ export declare enum SdkEventType {
16
16
  SESSION_ACCEPTED = "sessionAccepted",
17
17
  SEEKERS_UPDATED = "seekersUpdated",
18
18
  SESSION_STATUS_CHANGED = "sessionStatusChanged",
19
+ DISCUSSION_UPDATED = "discussionUpdated",
19
20
  ERROR = "error"
20
21
  }
21
22
  export interface SdkEventHandlers {
@@ -29,6 +30,7 @@ export interface SdkEventHandlers {
29
30
  [SdkEventType.SESSION_ACCEPTED]: (contactUserId: string) => void;
30
31
  [SdkEventType.SEEKERS_UPDATED]: (seekers: Uint8Array[]) => void;
31
32
  [SdkEventType.SESSION_STATUS_CHANGED]: (contactUserId: string, status: SessionStatus) => void;
33
+ [SdkEventType.DISCUSSION_UPDATED]: (contactUserId: string) => void;
32
34
  [SdkEventType.ERROR]: (error: Error, context: string) => void;
33
35
  }
34
36
  export declare class SdkEventEmitter {
@@ -18,6 +18,7 @@ export var SdkEventType;
18
18
  SdkEventType["SESSION_ACCEPTED"] = "sessionAccepted";
19
19
  SdkEventType["SEEKERS_UPDATED"] = "seekersUpdated";
20
20
  SdkEventType["SESSION_STATUS_CHANGED"] = "sessionStatusChanged";
21
+ SdkEventType["DISCUSSION_UPDATED"] = "discussionUpdated";
21
22
  SdkEventType["ERROR"] = "error";
22
23
  })(SdkEventType || (SdkEventType = {}));
23
24
  // ─────────────────────────────────────────────────────────────────────────────
@@ -40,6 +41,7 @@ export class SdkEventEmitter {
40
41
  [SdkEventType.SESSION_ACCEPTED]: new Set(),
41
42
  [SdkEventType.SEEKERS_UPDATED]: new Set(),
42
43
  [SdkEventType.SESSION_STATUS_CHANGED]: new Set(),
44
+ [SdkEventType.DISCUSSION_UPDATED]: new Set(),
43
45
  [SdkEventType.ERROR]: new Set(),
44
46
  }
45
47
  });
package/dist/db/db.d.ts CHANGED
@@ -100,7 +100,8 @@ export declare enum MessageType {
100
100
  AUDIO = "audio",
101
101
  VIDEO = "video",
102
102
  DELETED = "deleted",
103
- REACTION = "reaction"
103
+ REACTION = "reaction",
104
+ RETENTION_POLICY = "retention_policy"
104
105
  }
105
106
  export interface ReadyAnnouncement {
106
107
  announcement_bytes: Uint8Array;
@@ -142,6 +143,8 @@ export interface Discussion {
142
143
  lastMessageTimestamp: Date | null;
143
144
  unreadCount: number;
144
145
  pinned: boolean;
146
+ messageRetentionDuration: number | null;
147
+ retentionPolicySetAt: number | null;
145
148
  killedNextRetryAt?: Date | null;
146
149
  saturatedRetryAt?: Date | null;
147
150
  saturatedRetryDone: boolean;
package/dist/db/db.js CHANGED
@@ -38,6 +38,7 @@ export var MessageType;
38
38
  MessageType["VIDEO"] = "video";
39
39
  MessageType["DELETED"] = "deleted";
40
40
  MessageType["REACTION"] = "reaction";
41
+ MessageType["RETENTION_POLICY"] = "retention_policy";
41
42
  })(MessageType || (MessageType = {}));
42
43
  /** Serialize a SendAnnouncement to a JSON string for SQLite text column */
43
44
  export function serializeSendAnnouncement(announcement) {
@@ -54,4 +54,13 @@ export const MIGRATIONS = [
54
54
  when: 1742000000000,
55
55
  statements: ['ALTER TABLE `messages` ADD COLUMN `reactionOf` text;'],
56
56
  },
57
+ {
58
+ idx: 4,
59
+ tag: '0004_discussions_retention',
60
+ when: 1743000000000,
61
+ statements: [
62
+ 'ALTER TABLE `discussions` ADD COLUMN `messageRetentionDuration` integer;',
63
+ 'ALTER TABLE `discussions` ADD COLUMN `retentionPolicySetAt` integer;',
64
+ ],
65
+ },
57
66
  ];
@@ -10,6 +10,7 @@ export declare class DiscussionQueries {
10
10
  getById(id: number): Promise<DiscussionRow | undefined>;
11
11
  insert(values: DiscussionInsert): Promise<number>;
12
12
  updateById(id: number, data: Partial<DiscussionInsert>): Promise<void>;
13
+ updateByOwnerAndContact(ownerUserId: string, contactUserId: string, data: Partial<DiscussionInsert>): Promise<void>;
13
14
  deleteById(id: number): Promise<void>;
14
15
  deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
15
16
  incrementUnreadCount(discussionId: number): Promise<void>;
@@ -40,6 +40,12 @@ export class DiscussionQueries {
40
40
  .set(data)
41
41
  .where(eq(schema.discussions.id, id));
42
42
  }
43
+ async updateByOwnerAndContact(ownerUserId, contactUserId, data) {
44
+ await this.conn.db
45
+ .update(schema.discussions)
46
+ .set(data)
47
+ .where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.contactUserId, contactUserId)));
48
+ }
43
49
  async deleteById(id) {
44
50
  await this.conn.db
45
51
  .delete(schema.discussions)
@@ -1,3 +1,4 @@
1
+ import type { DiscussionRow } from './discussions.js';
1
2
  import * as schema from '../schema/index.js';
2
3
  import type { DatabaseConnection } from '../sqlite.js';
3
4
  import { MessageStatus } from '../../db/db.js';
@@ -9,6 +10,7 @@ export declare class MessageQueries {
9
10
  getById(id: number): Promise<MessageRow | undefined>;
10
11
  getByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
11
12
  getVisibleByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
13
+ getLastVisibleByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow | undefined>;
12
14
  getReactionsByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
13
15
  getByOwnerAndSeeker(ownerUserId: string, seeker: Uint8Array): Promise<MessageRow | undefined>;
14
16
  findByMessageId(ownerUserId: string, contactUserId: string, messageId: Uint8Array): Promise<MessageRow | undefined>;
@@ -24,6 +26,12 @@ export declare class MessageQueries {
24
26
  getByStatus(ownerUserId: string, status: MessageStatus): Promise<MessageRow[]>;
25
27
  resetSendQueue(ownerUserId: string, contactUserId: string): Promise<void>;
26
28
  getAnnouncementsByContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
29
+ /**
30
+ * Hard-delete messages older than each discussion's retention duration.
31
+ * Only processes discussions that have a non-null messageRetentionDuration.
32
+ * Skips KEEP_ALIVE and ANNOUNCEMENT types.
33
+ */
34
+ deleteExpiredByOwner(ownerUserId: string, discussions: DiscussionRow[]): Promise<void>;
27
35
  findDuplicateIncoming(ownerUserId: string, contactUserId: string, content: string, windowStart: Date, windowEnd: Date): Promise<{
28
36
  id: number;
29
37
  } | undefined>;
@@ -1,4 +1,4 @@
1
- import { eq, and, or, sql, inArray, asc, ne } from 'drizzle-orm';
1
+ import { eq, and, or, sql, inArray, asc, ne, lt, gte } from 'drizzle-orm';
2
2
  import * as schema from '../schema/index.js';
3
3
  import { MessageDirection, MessageStatus, MessageType } from '../../db/db.js';
4
4
  export class MessageQueries {
@@ -32,6 +32,8 @@ export class MessageQueries {
32
32
  .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId),
33
33
  // Hide keep-alive messages from UI
34
34
  ne(schema.messages.type, MessageType.KEEP_ALIVE),
35
+ // Hide retention policy control messages from UI
36
+ ne(schema.messages.type, MessageType.RETENTION_POLICY),
35
37
  // Hide reaction messages (and any deleted reaction rows) from main message list
36
38
  ne(schema.messages.type, MessageType.REACTION), sql `reactionOf IS NULL`,
37
39
  // Hide delete control messages (outgoing DELETED with empty content)
@@ -41,6 +43,15 @@ export class MessageQueries {
41
43
  .orderBy(asc(schema.messages.id))
42
44
  .all();
43
45
  }
46
+ async getLastVisibleByOwnerAndContact(ownerUserId, contactUserId) {
47
+ return this.conn.db
48
+ .select()
49
+ .from(schema.messages)
50
+ .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), ne(schema.messages.type, MessageType.KEEP_ALIVE), ne(schema.messages.type, MessageType.RETENTION_POLICY), ne(schema.messages.type, MessageType.REACTION), sql `reactionOf IS NULL`, or(ne(schema.messages.type, MessageType.DELETED), ne(schema.messages.direction, MessageDirection.OUTGOING), ne(schema.messages.content, '')), sql `(metadata IS NULL OR metadata NOT LIKE '%"control":"edit"%')`))
51
+ .orderBy(sql `${schema.messages.id} DESC`)
52
+ .limit(1)
53
+ .get();
54
+ }
44
55
  async getReactionsByOwnerAndContact(ownerUserId, contactUserId) {
45
56
  return this.conn.db
46
57
  .select()
@@ -159,6 +170,27 @@ export class MessageQueries {
159
170
  .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.direction, MessageDirection.INCOMING), eq(schema.messages.type, MessageType.ANNOUNCEMENT)))
160
171
  .all();
161
172
  }
173
+ /**
174
+ * Hard-delete messages older than each discussion's retention duration.
175
+ * Only processes discussions that have a non-null messageRetentionDuration.
176
+ * Skips KEEP_ALIVE and ANNOUNCEMENT types.
177
+ */
178
+ async deleteExpiredByOwner(ownerUserId, discussions) {
179
+ const now = Date.now();
180
+ for (const discussion of discussions) {
181
+ if (!discussion.messageRetentionDuration ||
182
+ discussion.messageRetentionDuration <= 0) {
183
+ continue;
184
+ }
185
+ const expiryTs = now - discussion.messageRetentionDuration * 1000;
186
+ // Only delete messages that were sent AFTER the policy was activated.
187
+ // Messages that existed before the policy was set are left untouched.
188
+ const policySetAt = discussion.retentionPolicySetAt ?? 0;
189
+ await this.conn.db
190
+ .delete(schema.messages)
191
+ .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, discussion.contactUserId), lt(schema.messages.timestamp, new Date(expiryTs)), gte(schema.messages.timestamp, new Date(policySetAt)), ne(schema.messages.type, MessageType.KEEP_ALIVE), ne(schema.messages.type, MessageType.ANNOUNCEMENT)));
192
+ }
193
+ }
162
194
  async findDuplicateIncoming(ownerUserId, contactUserId, content, windowStart, windowEnd) {
163
195
  return this.conn.db
164
196
  .select({ id: schema.messages.id })
@@ -328,6 +328,40 @@ export declare const discussions: import("drizzle-orm/sqlite-core").SQLiteTableW
328
328
  identity: undefined;
329
329
  generated: undefined;
330
330
  }, {}, {}>;
331
+ messageRetentionDuration: import("drizzle-orm/sqlite-core").SQLiteColumn<{
332
+ name: "messageRetentionDuration";
333
+ tableName: "discussions";
334
+ dataType: "number";
335
+ columnType: "SQLiteInteger";
336
+ data: number;
337
+ driverParam: number;
338
+ notNull: false;
339
+ hasDefault: false;
340
+ isPrimaryKey: false;
341
+ isAutoincrement: false;
342
+ hasRuntimeDefault: false;
343
+ enumValues: undefined;
344
+ baseColumn: never;
345
+ identity: undefined;
346
+ generated: undefined;
347
+ }, {}, {}>;
348
+ retentionPolicySetAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
349
+ name: "retentionPolicySetAt";
350
+ tableName: "discussions";
351
+ dataType: "number";
352
+ columnType: "SQLiteInteger";
353
+ data: number;
354
+ driverParam: number;
355
+ notNull: false;
356
+ hasDefault: false;
357
+ isPrimaryKey: false;
358
+ isAutoincrement: false;
359
+ hasRuntimeDefault: false;
360
+ enumValues: undefined;
361
+ baseColumn: never;
362
+ identity: undefined;
363
+ generated: undefined;
364
+ }, {}, {}>;
331
365
  saturatedRetryDone: import("drizzle-orm/sqlite-core").SQLiteColumn<{
332
366
  name: "saturatedRetryDone";
333
367
  tableName: "discussions";
@@ -25,6 +25,8 @@ export const discussions = sqliteTable('discussions', {
25
25
  pinned: integer('pinned', { mode: 'boolean' }).notNull().default(false),
26
26
  killedNextRetryAt: integer('killedNextRetryAt', { mode: 'timestamp_ms' }),
27
27
  saturatedRetryAt: integer('saturatedRetryAt', { mode: 'timestamp_ms' }),
28
+ messageRetentionDuration: integer('messageRetentionDuration'),
29
+ retentionPolicySetAt: integer('retentionPolicySetAt'), // nullable, ms timestamp when policy was last configured
28
30
  saturatedRetryDone: integer('saturatedRetryDone', { mode: 'boolean' })
29
31
  .notNull()
30
32
  .default(false),
package/dist/gossip.js CHANGED
@@ -240,6 +240,7 @@ class GossipSdk {
240
240
  this._announcement = new AnnouncementService(messageProtocol, session, this.eventEmitter, config, queries);
241
241
  this._discussion = new DiscussionService(this._announcement, session, this.eventEmitter, queries);
242
242
  this._message = new MessageService(messageProtocol, session, this.eventEmitter, config, queries);
243
+ this._discussion.setMessageService(this._message);
243
244
  this._refresh = new RefreshService(this._message, this._discussion, this._announcement, session, this.eventEmitter, queries, this.config);
244
245
  this._selfMessage = new SelfMessageService(queries, session.userIdEncoded, encryptionKey);
245
246
  await this._selfMessage.ensureDiscussionExists();
@@ -5,7 +5,8 @@ export declare enum MessageType {
5
5
  MESSAGE_TYPE_KEEP_ALIVE = 3,
6
6
  MESSAGE_TYPE_DELETE = 4,
7
7
  MESSAGE_TYPE_EDIT = 5,
8
- MESSAGE_TYPE_REACTION = 6
8
+ MESSAGE_TYPE_REACTION = 6,
9
+ MESSAGE_TYPE_RETENTION_POLICY = 7
9
10
  }
10
11
  export interface Message {
11
12
  messageType?: MessageType;
@@ -9,6 +9,7 @@ export var MessageType;
9
9
  MessageType[MessageType["MESSAGE_TYPE_DELETE"] = 4] = "MESSAGE_TYPE_DELETE";
10
10
  MessageType[MessageType["MESSAGE_TYPE_EDIT"] = 5] = "MESSAGE_TYPE_EDIT";
11
11
  MessageType[MessageType["MESSAGE_TYPE_REACTION"] = 6] = "MESSAGE_TYPE_REACTION";
12
+ MessageType[MessageType["MESSAGE_TYPE_RETENTION_POLICY"] = 7] = "MESSAGE_TYPE_RETENTION_POLICY";
12
13
  })(MessageType || (MessageType = {}));
13
14
  const textEncoder = new TextEncoder();
14
15
  const textDecoder = new TextDecoder();
@@ -10,6 +10,7 @@ import { SessionStatus } from '../wasm/bindings.js';
10
10
  import { AnnouncementService } from './announcement.js';
11
11
  import { SessionModule } from '../wasm/session.js';
12
12
  import { RefreshService } from './refresh.js';
13
+ import type { MessageService } from './message.js';
13
14
  import type { AuthService } from './auth.js';
14
15
  import { Result } from '../utils/type.js';
15
16
  import { SdkEventEmitter } from '../core/SdkEventEmitter.js';
@@ -40,10 +41,12 @@ export declare class DiscussionService {
40
41
  private session;
41
42
  private eventEmitter;
42
43
  private refreshService?;
44
+ private messageService?;
43
45
  private authService?;
44
46
  private queries;
45
47
  constructor(announcementService: AnnouncementService, session: SessionModule, eventEmitter: SdkEventEmitter, queries: Queries, refreshService?: RefreshService);
46
48
  setRefreshService(refreshService: RefreshService): void;
49
+ setMessageService(messageService: MessageService): void;
47
50
  setAuthService(authService: AuthService): void;
48
51
  /**
49
52
  * Initialize a discussion with a contact using SessionManager
@@ -103,4 +106,13 @@ export declare class DiscussionService {
103
106
  success: boolean;
104
107
  message?: string;
105
108
  }>;
109
+ /**
110
+ * Set the auto-delete retention policy for a discussion.
111
+ * Updates the local DB and sends a control message to the peer so both sides
112
+ * apply the same policy (last-write-wins).
113
+ *
114
+ * @param contactUserId - The contact user ID of the discussion
115
+ * @param durationSeconds - Retention duration in seconds, or null to disable
116
+ */
117
+ setRetentionPolicy(contactUserId: string, durationSeconds: number | null): Promise<void>;
106
118
  }
@@ -55,6 +55,12 @@ export class DiscussionService {
55
55
  writable: true,
56
56
  value: void 0
57
57
  });
58
+ Object.defineProperty(this, "messageService", {
59
+ enumerable: true,
60
+ configurable: true,
61
+ writable: true,
62
+ value: void 0
63
+ });
58
64
  Object.defineProperty(this, "authService", {
59
65
  enumerable: true,
60
66
  configurable: true,
@@ -76,6 +82,9 @@ export class DiscussionService {
76
82
  setRefreshService(refreshService) {
77
83
  this.refreshService = refreshService;
78
84
  }
85
+ setMessageService(messageService) {
86
+ this.messageService = messageService;
87
+ }
79
88
  setAuthService(authService) {
80
89
  this.authService = authService;
81
90
  }
@@ -325,4 +334,42 @@ export class DiscussionService {
325
334
  pin(discussionId, pinned) {
326
335
  return updateDiscussionPin(discussionId, pinned, this.queries);
327
336
  }
337
+ /**
338
+ * Set the auto-delete retention policy for a discussion.
339
+ * Updates the local DB and sends a control message to the peer so both sides
340
+ * apply the same policy (last-write-wins).
341
+ *
342
+ * @param contactUserId - The contact user ID of the discussion
343
+ * @param durationSeconds - Retention duration in seconds, or null to disable
344
+ */
345
+ async setRetentionPolicy(contactUserId, durationSeconds) {
346
+ const ownerUserId = this.session.userIdEncoded;
347
+ // Update local DB
348
+ await this.queries.discussions.updateByOwnerAndContact(ownerUserId, contactUserId, {
349
+ messageRetentionDuration: durationSeconds,
350
+ retentionPolicySetAt: durationSeconds ? Date.now() : null,
351
+ });
352
+ // Send control message to peer so they apply the same policy
353
+ if (this.messageService) {
354
+ const encodedDuration = durationSeconds && durationSeconds > 0 ? durationSeconds : 0;
355
+ await this.messageService.send({
356
+ ownerUserId,
357
+ contactUserId,
358
+ content: String(encodedDuration),
359
+ type: MessageType.RETENTION_POLICY,
360
+ direction: MessageDirection.OUTGOING,
361
+ status: MessageStatus.WAITING_SESSION,
362
+ timestamp: new Date(),
363
+ });
364
+ }
365
+ // Restore the correct lastMessageContent by re-deriving from the last
366
+ // visible message — the control message must not pollute the preview.
367
+ const lastVisible = await this.queries.messages.getLastVisibleByOwnerAndContact(ownerUserId, contactUserId);
368
+ await this.queries.discussions.updateByOwnerAndContact(ownerUserId, contactUserId, {
369
+ lastMessageContent: lastVisible?.content ?? null,
370
+ lastMessageTimestamp: lastVisible?.timestamp ?? null,
371
+ lastMessageId: lastVisible?.id ?? null,
372
+ });
373
+ await this.refreshService?.stateUpdate();
374
+ }
328
375
  }
@@ -111,5 +111,11 @@ export declare class MessageService {
111
111
  * control message so the peer can update their copy as well.
112
112
  */
113
113
  editMessage(id: number, newContent: string): Promise<boolean>;
114
+ /**
115
+ * Hard-delete messages that have exceeded their discussion retention duration.
116
+ * Called periodically from the background refresh cycle.
117
+ * Emits MESSAGE_RECEIVED if any messages were deleted to trigger UI refresh.
118
+ */
119
+ deleteExpiredMessages(ownerUserId: string): Promise<void>;
114
120
  markAsRead(id: number): Promise<boolean>;
115
121
  }
@@ -7,7 +7,7 @@
7
7
  import { MessageDirection, MessageStatus, MessageType, MESSAGE_ID_SIZE, } from '../db/index.js';
8
8
  import { decodeUserId, encodeUserId } from '../utils/userId.js';
9
9
  import { SessionStatus } from '../wasm/bindings.js';
10
- import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, serializeDeleteMessage, serializeEditMessage, serializeReactionMessage, deserializeMessage, } from '../utils/messageSerialization.js';
10
+ import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, serializeDeleteMessage, serializeEditMessage, serializeReactionMessage, serializeRetentionPolicyMessage, deserializeMessage, } from '../utils/messageSerialization.js';
11
11
  import { encodeToBase64, decodeFromBase64 } from '../utils/base64.js';
12
12
  import { sessionStatusToString } from '../wasm/session.js';
13
13
  import { Logger } from '../utils/logs.js';
@@ -316,7 +316,8 @@ export class MessageService {
316
316
  const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
317
317
  if (discussion &&
318
318
  message.type !== MessageType.KEEP_ALIVE &&
319
- message.type !== MessageType.REACTION) {
319
+ message.type !== MessageType.REACTION &&
320
+ message.type !== MessageType.RETENTION_POLICY) {
320
321
  await this.queries.discussions.updateById(discussion.id, {
321
322
  lastMessageId: messageId,
322
323
  lastMessageContent: message.content,
@@ -426,6 +427,20 @@ export class MessageService {
426
427
  // Do not insert a new message row for edit control messages
427
428
  continue;
428
429
  }
430
+ // Handle retention policy control messages by updating the discussion setting
431
+ if (message.type === MessageType.RETENTION_POLICY) {
432
+ const durationSeconds = parseInt(message.content, 10);
433
+ const duration = isNaN(durationSeconds) || durationSeconds <= 0
434
+ ? null
435
+ : durationSeconds;
436
+ await this.queries.discussions.updateByOwnerAndContact(ownerUserId, message.senderId, {
437
+ messageRetentionDuration: duration,
438
+ retentionPolicySetAt: duration ? Date.now() : null,
439
+ });
440
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, message.senderId);
441
+ // Do not insert a new message row for retention policy control messages
442
+ continue;
443
+ }
429
444
  // Handle reaction messages by inserting a separate row
430
445
  if (message.type === MessageType.REACTION &&
431
446
  message.reactionOf?.originalMsgId) {
@@ -575,8 +590,9 @@ export class MessageService {
575
590
  if (!discussion) {
576
591
  return { success: false, error: 'Discussion not found' };
577
592
  }
578
- // Generate a random messageId for deduplication (not for keep-alive)
579
- const randomMessageId = message.type !== MessageType.KEEP_ALIVE
593
+ // Generate a random messageId for deduplication (not for keep-alive or retention policy)
594
+ const randomMessageId = message.type !== MessageType.KEEP_ALIVE &&
595
+ message.type !== MessageType.RETENTION_POLICY
580
596
  ? crypto.getRandomValues(new Uint8Array(MESSAGE_ID_SIZE))
581
597
  : undefined;
582
598
  message.messageId = randomMessageId;
@@ -611,7 +627,9 @@ export class MessageService {
611
627
  }
612
628
  async serializeMessage(message) {
613
629
  const log = logger.forMethod('serializeMessage');
614
- if (!message.messageId && message.type !== MessageType.KEEP_ALIVE) {
630
+ if (!message.messageId &&
631
+ message.type !== MessageType.KEEP_ALIVE &&
632
+ message.type !== MessageType.RETENTION_POLICY) {
615
633
  return {
616
634
  success: false,
617
635
  error: 'Message ID is required',
@@ -636,6 +654,13 @@ export class MessageService {
636
654
  data: serializeKeepAliveMessage(),
637
655
  };
638
656
  }
657
+ else if (message.type === MessageType.RETENTION_POLICY) {
658
+ const durationSeconds = parseInt(message.content, 10);
659
+ return {
660
+ success: true,
661
+ data: serializeRetentionPolicyMessage(isNaN(durationSeconds) || durationSeconds < 0 ? 0 : durationSeconds),
662
+ };
663
+ }
639
664
  else if (message.type === MessageType.DELETED && message.deleteOf) {
640
665
  // Serialize a delete control message targeting an existing messageId
641
666
  const originalMsgId = message.deleteOf.originalMsgId;
@@ -1102,6 +1127,18 @@ export class MessageService {
1102
1127
  await this.refreshService?.stateUpdate();
1103
1128
  return true;
1104
1129
  }
1130
+ /**
1131
+ * Hard-delete messages that have exceeded their discussion retention duration.
1132
+ * Called periodically from the background refresh cycle.
1133
+ * Emits MESSAGE_RECEIVED if any messages were deleted to trigger UI refresh.
1134
+ */
1135
+ async deleteExpiredMessages(ownerUserId) {
1136
+ const allRows = await this.queries.discussions.getByOwner(ownerUserId);
1137
+ const withRetention = allRows.filter(d => d.messageRetentionDuration != null && d.messageRetentionDuration > 0);
1138
+ if (withRetention.length === 0)
1139
+ return;
1140
+ await this.queries.messages.deleteExpiredByOwner(ownerUserId, withRetention);
1141
+ }
1105
1142
  // 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.
1106
1143
  async markAsRead(id) {
1107
1144
  // Check current message status from DB to avoid race conditions
@@ -231,6 +231,8 @@ export class RefreshService {
231
231
  });
232
232
  }
233
233
  }
234
+ // Step 4: hard-delete messages that have exceeded retention duration
235
+ await this.messageService.deleteExpiredMessages(ownerUserId);
234
236
  }
235
237
  catch (error) {
236
238
  log.error('error in update_state', { error });
@@ -20,6 +20,11 @@ export declare class SelfMessageService {
20
20
  originalMessageId: number;
21
21
  }>;
22
22
  removeReaction(reactionId: number): Promise<void>;
23
+ getRetentionInfo(): Promise<{
24
+ duration: number | null;
25
+ setAt: number | null;
26
+ }>;
27
+ setRetentionPolicy(durationSeconds: number | null): Promise<void>;
23
28
  getReactions(): Promise<{
24
29
  id: number;
25
30
  emoji: string;
@@ -173,6 +173,20 @@ export class SelfMessageService {
173
173
  async removeReaction(reactionId) {
174
174
  await this.queries.messages.deleteById(reactionId);
175
175
  }
176
+ async getRetentionInfo() {
177
+ const row = await this.queries.discussions.getByOwnerAndContact(this.ownerUserId, SELF_CONTACT_ID);
178
+ return {
179
+ duration: row?.messageRetentionDuration ?? null,
180
+ setAt: row?.retentionPolicySetAt ?? null,
181
+ };
182
+ }
183
+ async setRetentionPolicy(durationSeconds) {
184
+ const duration = durationSeconds != null && durationSeconds > 0 ? durationSeconds : null;
185
+ await this.queries.discussions.updateByOwnerAndContact(this.ownerUserId, SELF_CONTACT_ID, {
186
+ messageRetentionDuration: duration,
187
+ retentionPolicySetAt: duration ? Date.now() : null,
188
+ });
189
+ }
176
190
  async getReactions() {
177
191
  const rows = await this.queries.messages.getReactionsByOwnerAndContact(this.ownerUserId, SELF_CONTACT_ID);
178
192
  const result = [];
@@ -9,6 +9,7 @@ import { MessageType as ProtoMessageType } from '../proto/generated/message.js';
9
9
  export declare const MESSAGE_TYPE_KEEP_ALIVE = ProtoMessageType.MESSAGE_TYPE_KEEP_ALIVE;
10
10
  export declare const MESSAGE_TYPE_DELETE = ProtoMessageType.MESSAGE_TYPE_DELETE;
11
11
  export declare const MESSAGE_TYPE_EDIT = ProtoMessageType.MESSAGE_TYPE_EDIT;
12
+ export declare const MESSAGE_TYPE_RETENTION_POLICY = ProtoMessageType.MESSAGE_TYPE_RETENTION_POLICY;
12
13
  export interface DeserializedMessage {
13
14
  content: string;
14
15
  messageId?: Uint8Array;
@@ -91,6 +92,13 @@ export declare function serializeDeleteMessage(originalMsgId: Uint8Array, messag
91
92
  */
92
93
  export declare function serializeEditMessage(newContent: string, originalMsgId: Uint8Array, messageId: Uint8Array): Uint8Array;
93
94
  export declare function serializeReactionMessage(emoji: string, originalMsgId: Uint8Array, messageId: Uint8Array): Uint8Array;
95
+ /**
96
+ * Serialize a retention policy control message
97
+ *
98
+ * @param durationSeconds - Retention duration in seconds (0 to disable)
99
+ * @returns Serialized retention policy message bytes
100
+ */
101
+ export declare function serializeRetentionPolicyMessage(durationSeconds: number): Uint8Array;
94
102
  /**
95
103
  * Deserialize a message from bytes
96
104
  *
@@ -9,6 +9,7 @@ import { Message as ProtoMessage, MessageType as ProtoMessageType, } from '../pr
9
9
  export const MESSAGE_TYPE_KEEP_ALIVE = ProtoMessageType.MESSAGE_TYPE_KEEP_ALIVE;
10
10
  export const MESSAGE_TYPE_DELETE = ProtoMessageType.MESSAGE_TYPE_DELETE;
11
11
  export const MESSAGE_TYPE_EDIT = ProtoMessageType.MESSAGE_TYPE_EDIT;
12
+ export const MESSAGE_TYPE_RETENTION_POLICY = ProtoMessageType.MESSAGE_TYPE_RETENTION_POLICY;
12
13
  /**
13
14
  * Serialize a keep-alive message
14
15
  * Keep-alive messages are used to maintain session activity
@@ -150,6 +151,18 @@ export function serializeReactionMessage(emoji, originalMsgId, messageId) {
150
151
  citedMsgId: originalMsgId,
151
152
  });
152
153
  }
154
+ /**
155
+ * Serialize a retention policy control message
156
+ *
157
+ * @param durationSeconds - Retention duration in seconds (0 to disable)
158
+ * @returns Serialized retention policy message bytes
159
+ */
160
+ export function serializeRetentionPolicyMessage(durationSeconds) {
161
+ return ProtoMessage.encode({
162
+ messageType: ProtoMessageType.MESSAGE_TYPE_RETENTION_POLICY,
163
+ content: String(durationSeconds),
164
+ });
165
+ }
153
166
  /**
154
167
  * Deserialize a message from bytes
155
168
  *
@@ -169,6 +182,12 @@ export function deserializeMessage(buffer) {
169
182
  type: MessageType.KEEP_ALIVE,
170
183
  };
171
184
  }
185
+ if (protoType === ProtoMessageType.MESSAGE_TYPE_RETENTION_POLICY) {
186
+ return {
187
+ content: decoded.content ?? '0',
188
+ type: MessageType.RETENTION_POLICY,
189
+ };
190
+ }
172
191
  const content = decoded.content ?? '';
173
192
  const messageId = decoded.messageId;
174
193
  const citedMsgId = decoded.citedMsgId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/gossip-sdk",
3
- "version": "0.0.2-dev.20260323092903",
3
+ "version": "0.0.2-dev.20260324105033",
4
4
  "description": "Gossip SDK for automation, chatbot, and integration use cases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",