@massalabs/gossip-sdk 0.0.2-dev.20260428144322 → 0.0.2-dev.20260429045217

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.
@@ -18,6 +18,7 @@ export declare enum SdkEventType {
18
18
  SESSION_STATUS_CHANGED = "sessionStatusChanged",
19
19
  DISCUSSION_UPDATED = "discussionUpdated",
20
20
  MESSAGE_ACKNOWLEDGED = "messageAcknowledged",
21
+ CONTACT_DELETED = "contactDeleted",
21
22
  ERROR = "error"
22
23
  }
23
24
  export type SdkEvents = {
@@ -53,6 +54,9 @@ export type SdkEvents = {
53
54
  contactUserId: string;
54
55
  messageDbId: number;
55
56
  };
57
+ [SdkEventType.CONTACT_DELETED]: {
58
+ contactUserId: string;
59
+ };
56
60
  [SdkEventType.ERROR]: {
57
61
  error: Error;
58
62
  context: string;
@@ -18,6 +18,7 @@ export var SdkEventType;
18
18
  SdkEventType["SESSION_STATUS_CHANGED"] = "sessionStatusChanged";
19
19
  SdkEventType["DISCUSSION_UPDATED"] = "discussionUpdated";
20
20
  SdkEventType["MESSAGE_ACKNOWLEDGED"] = "messageAcknowledged";
21
+ SdkEventType["CONTACT_DELETED"] = "contactDeleted";
21
22
  SdkEventType["ERROR"] = "error";
22
23
  })(SdkEventType || (SdkEventType = {}));
23
24
  export class SdkEventEmitter {
@@ -5,7 +5,7 @@
5
5
  * pending ones. Each migration executes in its own transaction so
6
6
  * a failure at migration N leaves 0..N-1 committed.
7
7
  */
8
- type ExecRaw = (sql: string, params?: unknown[]) => Promise<unknown[][]>;
9
- type WithTransaction = <T>(fn: () => Promise<T>) => Promise<T>;
8
+ export type ExecRaw = (sql: string, params?: unknown[]) => Promise<unknown[][]>;
9
+ type WithTransaction = <T>(fn: (txExecRaw: ExecRaw) => Promise<T>) => Promise<T>;
10
10
  export declare function runMigrations(execRaw: ExecRaw, withTransaction: WithTransaction): Promise<void>;
11
11
  export {};
@@ -25,11 +25,11 @@ export async function runMigrations(execRaw, withTransaction) {
25
25
  const maxApplied = rows[0][0];
26
26
  const pending = MIGRATIONS.filter(m => m.idx > (maxApplied ?? -1));
27
27
  for (const migration of pending) {
28
- await withTransaction(async () => {
28
+ await withTransaction(async (txExecRaw) => {
29
29
  for (const stmt of migration.statements) {
30
- await execRaw(makeCreateStatementsIdempotent(stmt));
30
+ await txExecRaw(makeCreateStatementsIdempotent(stmt));
31
31
  }
32
- await execRaw('INSERT INTO _migrations (idx, tag, applied_at) VALUES (?, ?, ?)', [migration.idx, migration.tag, Date.now()]);
32
+ await txExecRaw('INSERT INTO _migrations (idx, tag, applied_at) VALUES (?, ?, ?)', [migration.idx, migration.tag, Date.now()]);
33
33
  });
34
34
  }
35
35
  }
@@ -1,5 +1,5 @@
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
3
  import type { Contact } from '../db.js';
4
4
  export type ContactRow = typeof schema.contacts.$inferSelect;
5
5
  type ContactInsert = typeof schema.contacts.$inferInsert;
@@ -10,7 +10,7 @@ export declare class ContactQueries {
10
10
  getByOwner(ownerUserId: string): Promise<Contact[]>;
11
11
  insert(values: ContactInsert): Promise<number>;
12
12
  updateByOwnerAndUser(ownerUserId: string, userId: string, data: Partial<ContactInsert>): Promise<void>;
13
- deleteByOwnerAndUser(ownerUserId: string, userId: string): Promise<void>;
13
+ deleteByOwnerAndUser(ownerUserId: string, userId: string, tx?: GossipSqliteTx): Promise<void>;
14
14
  getNamesByPrefix(ownerUserId: string, prefix: string): Promise<{
15
15
  name: string;
16
16
  }[]>;
@@ -37,8 +37,8 @@ export class ContactQueries {
37
37
  .set(data)
38
38
  .where(and(eq(schema.contacts.ownerUserId, ownerUserId), eq(schema.contacts.userId, userId)));
39
39
  }
40
- async deleteByOwnerAndUser(ownerUserId, userId) {
41
- await this.conn.db
40
+ async deleteByOwnerAndUser(ownerUserId, userId, tx) {
41
+ await (tx ?? this.conn.db)
42
42
  .delete(schema.contacts)
43
43
  .where(and(eq(schema.contacts.ownerUserId, ownerUserId), eq(schema.contacts.userId, userId)));
44
44
  }
@@ -14,7 +14,7 @@ export declare class DiscussionQueries {
14
14
  updateById(id: number, data: Partial<DiscussionInsert>, tx?: GossipSqliteTx): Promise<void>;
15
15
  updateByOwnerAndContact(ownerUserId: string, contactUserId: string, data: Partial<DiscussionInsert>): Promise<void>;
16
16
  deleteById(id: number): Promise<void>;
17
- deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
17
+ deleteByOwnerAndContact(ownerUserId: string, contactUserId: string, tx?: GossipSqliteTx): Promise<void>;
18
18
  incrementUnreadCount(discussionId: number, tx?: GossipSqliteTx): Promise<void>;
19
19
  decrementUnreadCount(discussionId: number, tx?: GossipSqliteTx): Promise<void>;
20
20
  }
@@ -61,8 +61,8 @@ export class DiscussionQueries {
61
61
  .delete(schema.discussions)
62
62
  .where(eq(schema.discussions.id, id));
63
63
  }
64
- async deleteByOwnerAndContact(ownerUserId, contactUserId) {
65
- await this.conn.db
64
+ async deleteByOwnerAndContact(ownerUserId, contactUserId, tx) {
65
+ await (tx ?? this.conn.db)
66
66
  .delete(schema.discussions)
67
67
  .where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.contactUserId, contactUserId)));
68
68
  }
@@ -17,8 +17,8 @@ export declare class MessageQueries {
17
17
  insert(values: MessageInsert, tx?: GossipSqliteTx): Promise<number>;
18
18
  batchInsert(values: MessageInsert[]): Promise<void>;
19
19
  updateById(id: number, data: Partial<MessageInsert>, tx?: GossipSqliteTx): Promise<void>;
20
- deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
21
- deleteById(id: number): Promise<void>;
20
+ deleteByOwnerAndContact(ownerUserId: string, contactUserId: string, tx?: GossipSqliteTx): Promise<void>;
21
+ deleteById(id: number, tx?: GossipSqliteTx): Promise<void>;
22
22
  deleteReactionsForMessage(ownerUserId: string, contactUserId: string, messageIdBase64: string): Promise<void>;
23
23
  deleteDeliveredKeepAlive(ownerUserId: string): Promise<void>;
24
24
  getOutgoingSentByOwner(ownerUserId: string): Promise<MessageRow[]>;
@@ -27,12 +27,7 @@ export declare class MessageQueries {
27
27
  getByStatus(ownerUserId: string, status: MessageStatus): Promise<MessageRow[]>;
28
28
  resetSendQueue(ownerUserId: string, contactUserId: string): Promise<void>;
29
29
  getAnnouncementsByContact(ownerUserId: string, contactUserId: string): Promise<MessageRow[]>;
30
- /**
31
- * Hard-delete messages older than each discussion's retention duration.
32
- * Only processes discussions that have a non-null messageRetentionDuration.
33
- * Skips KEEP_ALIVE and ANNOUNCEMENT types.
34
- */
35
- deleteExpiredByOwner(ownerUserId: string, discussions: DiscussionRow[]): Promise<void>;
30
+ getExpiredByOwner(ownerUserId: string, discussions: DiscussionRow[]): Promise<MessageRow[]>;
36
31
  findDuplicateIncoming(ownerUserId: string, contactUserId: string, content: string, windowStart: Date, windowEnd: Date): Promise<{
37
32
  id: number;
38
33
  } | undefined>;
@@ -75,8 +75,15 @@ export class MessageQueries {
75
75
  .get();
76
76
  }
77
77
  async insert(values, tx) {
78
- await (tx ?? this.conn.db).insert(schema.messages).values(values);
79
- return this.conn.getLastInsertRowId();
78
+ const inserted = await (tx ?? this.conn.db)
79
+ .insert(schema.messages)
80
+ .values(values)
81
+ .returning({ id: schema.messages.id })
82
+ .get();
83
+ if (!inserted?.id) {
84
+ throw new Error('Failed to insert message row');
85
+ }
86
+ return inserted.id;
80
87
  }
81
88
  async batchInsert(values) {
82
89
  if (values.length === 0)
@@ -89,13 +96,13 @@ export class MessageQueries {
89
96
  .set(data)
90
97
  .where(eq(schema.messages.id, id));
91
98
  }
92
- async deleteByOwnerAndContact(ownerUserId, contactUserId) {
93
- await this.conn.db
99
+ async deleteByOwnerAndContact(ownerUserId, contactUserId, tx) {
100
+ await (tx ?? this.conn.db)
94
101
  .delete(schema.messages)
95
102
  .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId)));
96
103
  }
97
- async deleteById(id) {
98
- await this.conn.db
104
+ async deleteById(id, tx) {
105
+ await (tx ?? this.conn.db)
99
106
  .delete(schema.messages)
100
107
  .where(eq(schema.messages.id, id));
101
108
  }
@@ -175,26 +182,26 @@ export class MessageQueries {
175
182
  .where(and(eq(schema.messages.ownerUserId, ownerUserId), eq(schema.messages.contactUserId, contactUserId), eq(schema.messages.direction, MessageDirection.INCOMING), eq(schema.messages.type, MessageType.ANNOUNCEMENT)))
176
183
  .all();
177
184
  }
178
- /**
179
- * Hard-delete messages older than each discussion's retention duration.
180
- * Only processes discussions that have a non-null messageRetentionDuration.
181
- * Skips KEEP_ALIVE and ANNOUNCEMENT types.
182
- */
183
- async deleteExpiredByOwner(ownerUserId, discussions) {
185
+ async getExpiredByOwner(ownerUserId, discussions) {
184
186
  const now = Date.now();
187
+ const expiredMessages = [];
185
188
  for (const discussion of discussions) {
186
189
  if (!discussion.messageRetentionDuration ||
187
190
  discussion.messageRetentionDuration <= 0) {
188
191
  continue;
189
192
  }
190
193
  const expiryTs = now - discussion.messageRetentionDuration * 1000;
191
- // Only delete messages that were sent AFTER the policy was activated.
194
+ // Only include messages that were sent AFTER the policy was activated.
192
195
  // Messages that existed before the policy was set are left untouched.
193
196
  const policySetAt = discussion.retentionPolicySetAt ?? 0;
194
- await this.conn.db
195
- .delete(schema.messages)
196
- .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)));
197
+ const rows = await this.conn.db
198
+ .select()
199
+ .from(schema.messages)
200
+ .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)))
201
+ .all();
202
+ expiredMessages.push(...rows);
197
203
  }
204
+ return expiredMessages;
198
205
  }
199
206
  async findDuplicateIncoming(ownerUserId, contactUserId, content, windowStart, windowEnd) {
200
207
  return this.conn.db
@@ -0,0 +1,81 @@
1
+ import { QueryBuilder } from 'drizzle-orm/sqlite-core';
2
+ import type { GossipDatabase, GossipSqliteTx } from '../sqlite.js';
3
+ /**
4
+ * A db context accepted by SqlBatch operations.
5
+ *
6
+ * Both the root database instance and an active transaction expose the same
7
+ * drizzle query-builder API (insert / update / delete / select), so batch ops
8
+ * can be written generically and replayed on either surface.
9
+ */
10
+ export type DbContext = GossipDatabase | GossipSqliteTx;
11
+ type BatchOp = (db: DbContext) => Promise<void>;
12
+ /**
13
+ * A collection of deferred database write operations that can be executed
14
+ * atomically within an explicit transaction context.
15
+ *
16
+ * ## Motivation
17
+ *
18
+ * When a helper method needs to produce DB writes that must be committed
19
+ * together with the caller's own writes, passing a `tx` reference through
20
+ * every layer couples each helper to whatever transaction the caller happens
21
+ * to be running. SqlBatch inverts this: the helper collects its operations
22
+ * without executing them, returns the batch, and the caller replays it in
23
+ * whichever transaction it controls.
24
+ *
25
+ * ## Usage
26
+ *
27
+ * ```ts
28
+ * // --- building the batch (no DB writes yet) ---
29
+ * const batch = new SqlBatch();
30
+ * const values = { content: 'hello', ... };
31
+ * batch.add(db => db.insert(schema.messages).values(values));
32
+ * batch.add(db => db.update(schema.discussions).set({ ... }).where(...));
33
+ *
34
+ * // --- replaying in the caller's transaction ---
35
+ * await this.queries.conn.withTransaction(async tx => {
36
+ * await doSomethingElse(tx);
37
+ * await batch.execute(tx); // all ops run inside the same tx
38
+ * });
39
+ * ```
40
+ *
41
+ * ## QueryBuilder
42
+ *
43
+ * The `qb` property exposes a connection-less `QueryBuilder` that can be used
44
+ * to build and inspect queries (e.g. via `.toSQL()`) before capturing them in
45
+ * a batch op. It is purely a convenience — most callers will simply use the
46
+ * `db` argument provided by the op factory.
47
+ */
48
+ export declare class SqlBatch {
49
+ /**
50
+ * A connection-less QueryBuilder for constructing parameterised queries
51
+ * without a live database connection. Inspect with `.toSQL()` or capture
52
+ * the built values in a closure passed to `add()`.
53
+ */
54
+ readonly qb: QueryBuilder;
55
+ private readonly ops;
56
+ /**
57
+ * Queue a write operation to be executed later.
58
+ *
59
+ * The factory receives the actual db context (transaction or root db) at
60
+ * execute time. Use drizzle's standard query builders — `db.insert()`,
61
+ * `db.update()`, `db.delete()` — with the provided `db` argument.
62
+ *
63
+ * Values needed by the operation should be closed over from the surrounding
64
+ * scope so that the batch remains a self-contained description of what to
65
+ * write.
66
+ */
67
+ add(op: BatchOp): void;
68
+ /**
69
+ * Execute all queued operations in insertion order using the provided db
70
+ * context.
71
+ *
72
+ * Pass an active transaction (`tx`) for atomic execution alongside other
73
+ * operations in the same transaction. When no transaction is available,
74
+ * pass the root db instance — each op will then run as its own implicit
75
+ * transaction.
76
+ */
77
+ execute(db: DbContext): Promise<void>;
78
+ /** Number of queued operations. */
79
+ get size(): number;
80
+ }
81
+ export {};
@@ -0,0 +1,90 @@
1
+ import { QueryBuilder } from 'drizzle-orm/sqlite-core';
2
+ /**
3
+ * A collection of deferred database write operations that can be executed
4
+ * atomically within an explicit transaction context.
5
+ *
6
+ * ## Motivation
7
+ *
8
+ * When a helper method needs to produce DB writes that must be committed
9
+ * together with the caller's own writes, passing a `tx` reference through
10
+ * every layer couples each helper to whatever transaction the caller happens
11
+ * to be running. SqlBatch inverts this: the helper collects its operations
12
+ * without executing them, returns the batch, and the caller replays it in
13
+ * whichever transaction it controls.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```ts
18
+ * // --- building the batch (no DB writes yet) ---
19
+ * const batch = new SqlBatch();
20
+ * const values = { content: 'hello', ... };
21
+ * batch.add(db => db.insert(schema.messages).values(values));
22
+ * batch.add(db => db.update(schema.discussions).set({ ... }).where(...));
23
+ *
24
+ * // --- replaying in the caller's transaction ---
25
+ * await this.queries.conn.withTransaction(async tx => {
26
+ * await doSomethingElse(tx);
27
+ * await batch.execute(tx); // all ops run inside the same tx
28
+ * });
29
+ * ```
30
+ *
31
+ * ## QueryBuilder
32
+ *
33
+ * The `qb` property exposes a connection-less `QueryBuilder` that can be used
34
+ * to build and inspect queries (e.g. via `.toSQL()`) before capturing them in
35
+ * a batch op. It is purely a convenience — most callers will simply use the
36
+ * `db` argument provided by the op factory.
37
+ */
38
+ export class SqlBatch {
39
+ constructor() {
40
+ /**
41
+ * A connection-less QueryBuilder for constructing parameterised queries
42
+ * without a live database connection. Inspect with `.toSQL()` or capture
43
+ * the built values in a closure passed to `add()`.
44
+ */
45
+ Object.defineProperty(this, "qb", {
46
+ enumerable: true,
47
+ configurable: true,
48
+ writable: true,
49
+ value: new QueryBuilder()
50
+ });
51
+ Object.defineProperty(this, "ops", {
52
+ enumerable: true,
53
+ configurable: true,
54
+ writable: true,
55
+ value: []
56
+ });
57
+ }
58
+ /**
59
+ * Queue a write operation to be executed later.
60
+ *
61
+ * The factory receives the actual db context (transaction or root db) at
62
+ * execute time. Use drizzle's standard query builders — `db.insert()`,
63
+ * `db.update()`, `db.delete()` — with the provided `db` argument.
64
+ *
65
+ * Values needed by the operation should be closed over from the surrounding
66
+ * scope so that the batch remains a self-contained description of what to
67
+ * write.
68
+ */
69
+ add(op) {
70
+ this.ops.push(op);
71
+ }
72
+ /**
73
+ * Execute all queued operations in insertion order using the provided db
74
+ * context.
75
+ *
76
+ * Pass an active transaction (`tx`) for atomic execution alongside other
77
+ * operations in the same transaction. When no transaction is available,
78
+ * pass the root db instance — each op will then run as its own implicit
79
+ * transaction.
80
+ */
81
+ async execute(db) {
82
+ for (const op of this.ops) {
83
+ await op(db);
84
+ }
85
+ }
86
+ /** Number of queued operations. */
87
+ get size() {
88
+ return this.ops.length;
89
+ }
90
+ }
@@ -54,7 +54,11 @@ export declare class DatabaseConnection {
54
54
  private execRawInProcess;
55
55
  private init;
56
56
  getLastInsertRowId(): Promise<number>;
57
- withTransaction<T>(fn: () => Promise<T>): Promise<T>;
57
+ withTransaction<T>(fn: (tx: GossipSqliteTx) => Promise<T>, behavior?: 'deferred' | 'immediate' | 'exclusive'): Promise<T>;
58
+ private withRawTransaction;
59
+ private withSavepoint;
60
+ private runInTxScope;
61
+ private initTxScopeGuard;
58
62
  close(): Promise<void>;
59
63
  clearAllTables(): Promise<void>;
60
64
  /** Delete only the data belonging to a specific account. */
package/dist/db/sqlite.js CHANGED
@@ -29,13 +29,14 @@ function createDefaultState() {
29
29
  useWorker: false,
30
30
  drizzleDb: null,
31
31
  dbLock: Promise.resolve(),
32
- inTransaction: false,
32
+ txScopeGuard: null,
33
33
  };
34
34
  }
35
35
  /** PRAGMAs applied before migrations (in-memory / browser worker). */
36
36
  const PRAGMAS = `
37
37
  PRAGMA journal_mode=MEMORY;
38
38
  PRAGMA temp_store=MEMORY;
39
+ PRAGMA busy_timeout = 10000;
39
40
  `;
40
41
  /** PRAGMAs for file-based persistence (Node.js). WAL gives crash recovery. */
41
42
  const PRAGMAS_FILE = `
@@ -102,18 +103,27 @@ export class DatabaseConnection {
102
103
  this.state.worker.postMessage({ ...msg, id }, transfer);
103
104
  });
104
105
  }
105
- createDrizzleInstance() {
106
- return drizzle(async (sql, params, method) => {
107
- const rows = await this.execRaw(sql, params);
106
+ createDrizzleInstance(isTx = false, txContext) {
107
+ const drizzleDb = drizzle(async (sql, params, method) => {
108
+ const rows = isTx
109
+ ? await this.execRawDirect(sql, params)
110
+ : await this.execRaw(sql, params);
108
111
  if (method === 'get') {
109
112
  return { rows: rows[0] };
110
113
  }
111
114
  return { rows };
112
115
  }, { schema });
116
+ drizzleDb.transaction = async (fn) => {
117
+ if (txContext?.isActive) {
118
+ return this.withSavepoint(txContext, fn);
119
+ }
120
+ return this.withTransaction(fn);
121
+ };
122
+ return drizzleDb;
113
123
  }
114
124
  async execRaw(sql, params = []) {
115
- if (this.state.inTransaction) {
116
- return this.execRawDirect(sql, params);
125
+ if (this.state.txScopeGuard?.getStore()) {
126
+ throw new Error('Detected root db query inside a transaction callback. Use the provided transaction (tx) instance instead of root db.');
117
127
  }
118
128
  const prev = this.state.dbLock;
119
129
  let release;
@@ -144,6 +154,7 @@ export class DatabaseConnection {
144
154
  async init(options) {
145
155
  if (this.state.drizzleDb)
146
156
  return;
157
+ await this.initTxScopeGuard();
147
158
  const storage = options.storage ?? { type: 'memory' };
148
159
  switch (storage.type) {
149
160
  case 'opfs':
@@ -207,7 +218,10 @@ export class DatabaseConnection {
207
218
  break;
208
219
  }
209
220
  }
210
- await runMigrations((sql, params) => this.execRaw(sql, params), fn => this.withTransaction(fn));
221
+ await runMigrations((sql, params) => this.execRaw(sql, params), fn => {
222
+ const txExecRaw = this.execRawDirect.bind(this);
223
+ return this.withRawTransaction(() => fn(txExecRaw));
224
+ });
211
225
  this.state.drizzleDb = this.createDrizzleInstance();
212
226
  }
213
227
  // ─── Public methods ────────────────────────────────────────────
@@ -218,16 +232,31 @@ export class DatabaseConnection {
218
232
  const rows = await this.execRaw('SELECT last_insert_rowid()');
219
233
  return rows[0][0];
220
234
  }
221
- async withTransaction(fn) {
235
+ async withTransaction(fn, behavior = 'immediate') {
236
+ const txContext = {
237
+ nextSavepointId: 0,
238
+ isActive: false,
239
+ };
240
+ const tx = this.createDrizzleInstance(true, txContext);
241
+ return this.withRawTransaction(async () => {
242
+ txContext.isActive = true;
243
+ try {
244
+ return await fn(tx);
245
+ }
246
+ finally {
247
+ txContext.isActive = false;
248
+ }
249
+ }, behavior);
250
+ }
251
+ async withRawTransaction(fn, behavior = 'immediate') {
222
252
  const prev = this.state.dbLock;
223
253
  let release;
224
254
  this.state.dbLock = new Promise(r => (release = r));
225
255
  await prev;
226
256
  try {
227
- await this.execRawDirect('BEGIN');
228
- this.state.inTransaction = true;
257
+ await this.execRawDirect(`BEGIN ${behavior.toUpperCase()}`);
229
258
  try {
230
- const result = await fn();
259
+ const result = await this.runInTxScope(fn);
231
260
  await this.execRawDirect('COMMIT');
232
261
  return result;
233
262
  }
@@ -235,14 +264,52 @@ export class DatabaseConnection {
235
264
  await this.execRawDirect('ROLLBACK');
236
265
  throw e;
237
266
  }
238
- finally {
239
- this.state.inTransaction = false;
240
- }
241
267
  }
242
268
  finally {
243
269
  release();
244
270
  }
245
271
  }
272
+ async withSavepoint(txContext, fn) {
273
+ const savepointName = `sp_${txContext.nextSavepointId++}`;
274
+ const tx = this.createDrizzleInstance(true, txContext);
275
+ await this.execRawDirect(`SAVEPOINT ${savepointName}`);
276
+ try {
277
+ const result = await fn(tx);
278
+ await this.execRawDirect(`RELEASE SAVEPOINT ${savepointName}`);
279
+ return result;
280
+ }
281
+ catch (e) {
282
+ await this.execRawDirect(`ROLLBACK TO SAVEPOINT ${savepointName}`);
283
+ await this.execRawDirect(`RELEASE SAVEPOINT ${savepointName}`);
284
+ throw e;
285
+ }
286
+ }
287
+ async runInTxScope(fn) {
288
+ const guard = this.state.txScopeGuard;
289
+ if (!guard) {
290
+ return fn();
291
+ }
292
+ return guard.run(true, fn);
293
+ }
294
+ async initTxScopeGuard() {
295
+ // AsyncLocalStorage is Node-only. This guard is intentionally enabled only
296
+ // in Node/test environments; browser/worker runtimes skip it gracefully.
297
+ if (typeof process === 'undefined' ||
298
+ typeof process.versions?.node !== 'string') {
299
+ return;
300
+ }
301
+ try {
302
+ const { AsyncLocalStorage } = await import('node:async_hooks');
303
+ const guard = new AsyncLocalStorage();
304
+ this.state.txScopeGuard = {
305
+ run: (inTransaction, fn) => guard.run(inTransaction, fn),
306
+ getStore: () => guard.getStore() ?? false,
307
+ };
308
+ }
309
+ catch {
310
+ this.state.txScopeGuard = null;
311
+ }
312
+ }
246
313
  async close() {
247
314
  if (this.state.useWorker && this.state.worker) {
248
315
  await this.postToWorker({ type: 'close' });
@@ -254,49 +321,49 @@ export class DatabaseConnection {
254
321
  this.state = createDefaultState();
255
322
  }
256
323
  async clearAllTables() {
257
- await this.withTransaction(async () => {
258
- await this.db.delete(schema.messages);
259
- await this.db.delete(schema.discussions);
260
- await this.db.delete(schema.contacts);
261
- await this.db.delete(schema.userProfile);
262
- await this.db.delete(schema.pendingEncryptedMessages);
263
- await this.db.delete(schema.pendingAnnouncements);
264
- await this.db.delete(schema.activeSeekers);
265
- await this.db.delete(schema.announcementCursors);
324
+ await this.withTransaction(async (tx) => {
325
+ await tx.delete(schema.messages);
326
+ await tx.delete(schema.discussions);
327
+ await tx.delete(schema.contacts);
328
+ await tx.delete(schema.userProfile);
329
+ await tx.delete(schema.pendingEncryptedMessages);
330
+ await tx.delete(schema.pendingAnnouncements);
331
+ await tx.delete(schema.activeSeekers);
332
+ await tx.delete(schema.announcementCursors);
266
333
  });
267
334
  }
268
335
  /** Delete only the data belonging to a specific account. */
269
336
  async clearAccountData(userId) {
270
- await this.withTransaction(async () => {
337
+ await this.withTransaction(async (tx) => {
271
338
  // Tables with ownerUserId
272
- await this.db
339
+ await tx
273
340
  .delete(schema.messages)
274
341
  .where(eq(schema.messages.ownerUserId, userId));
275
- await this.db
342
+ await tx
276
343
  .delete(schema.discussions)
277
344
  .where(eq(schema.discussions.ownerUserId, userId));
278
- await this.db
345
+ await tx
279
346
  .delete(schema.contacts)
280
347
  .where(eq(schema.contacts.ownerUserId, userId));
281
348
  // Profile table keyed by userId
282
- await this.db
349
+ await tx
283
350
  .delete(schema.userProfile)
284
351
  .where(eq(schema.userProfile.userId, userId));
285
352
  // Announcement cursor keyed by userId
286
- await this.db
353
+ await tx
287
354
  .delete(schema.announcementCursors)
288
355
  .where(eq(schema.announcementCursors.userId, userId));
289
356
  // Session-specific tables (no user column — safe to clear for current session)
290
- await this.db.delete(schema.pendingEncryptedMessages);
291
- await this.db.delete(schema.pendingAnnouncements);
292
- await this.db.delete(schema.activeSeekers);
357
+ await tx.delete(schema.pendingEncryptedMessages);
358
+ await tx.delete(schema.pendingAnnouncements);
359
+ await tx.delete(schema.activeSeekers);
293
360
  });
294
361
  }
295
362
  async clearConversationTables() {
296
- await this.withTransaction(async () => {
297
- await this.db.delete(schema.messages);
298
- await this.db.delete(schema.discussions);
299
- await this.db.delete(schema.contacts);
363
+ await this.withTransaction(async (tx) => {
364
+ await tx.delete(schema.messages);
365
+ await tx.delete(schema.discussions);
366
+ await tx.delete(schema.contacts);
300
367
  });
301
368
  }
302
369
  }
package/dist/gossip.js CHANGED
@@ -256,7 +256,6 @@ class GossipSdk {
256
256
  });
257
257
  // Now set refreshService on services (circular dependency resolved via setter)
258
258
  this._discussion.setRefreshService(this._refresh);
259
- this._message.setRefreshService(this._refresh);
260
259
  this._announcement.setRefreshService(this._refresh);
261
260
  // Reset any messages stuck in SENDING status to WAITING_SESSION
262
261
  // This handles app crash/close during message send
@@ -272,7 +271,7 @@ class GossipSdk {
272
271
  onPersist: options.onPersist,
273
272
  };
274
273
  // Wire up cross-service dependencies
275
- this._contact = new ContactService(session, queries, this._auth);
274
+ this._contact = new ContactService(session, queries, this._auth, this.eventEmitter);
276
275
  this._message.setQueueManager(this.messageQueues);
277
276
  this._discussion.setAuthService(this._auth);
278
277
  // Auto-start polling if enabled in config
@@ -10,11 +10,13 @@ import type { SessionModule } from '../wasm/session.js';
10
10
  import type { AuthService } from './auth.js';
11
11
  import { type AddContactResult, type UpdateContactNameResult, type DeleteContactResult } from '../utils/contacts.js';
12
12
  import { Queries } from '../db/queries/index.js';
13
+ import { SdkEventEmitter } from '../core/SdkEventEmitter.js';
13
14
  export declare class ContactService {
14
15
  private session;
15
16
  private queries;
16
17
  private authService;
17
- constructor(session: SessionModule, queries: Queries, authService: AuthService);
18
+ private eventEmitter;
19
+ constructor(session: SessionModule, queries: Queries, authService: AuthService, eventEmitter: SdkEventEmitter);
18
20
  private get owner();
19
21
  list(): Promise<Contact[]>;
20
22
  get(contactUserId: string): Promise<Contact | null>;
@@ -5,8 +5,9 @@
5
5
  * Created during openSession().
6
6
  */
7
7
  import { addContact, updateContactName, deleteContact, } from '../utils/contacts.js';
8
+ import { SdkEventType } from '../core/SdkEventEmitter.js';
8
9
  export class ContactService {
9
- constructor(session, queries, authService) {
10
+ constructor(session, queries, authService, eventEmitter) {
10
11
  Object.defineProperty(this, "session", {
11
12
  enumerable: true,
12
13
  configurable: true,
@@ -25,9 +26,16 @@ export class ContactService {
25
26
  writable: true,
26
27
  value: void 0
27
28
  });
29
+ Object.defineProperty(this, "eventEmitter", {
30
+ enumerable: true,
31
+ configurable: true,
32
+ writable: true,
33
+ value: void 0
34
+ });
28
35
  this.session = session;
29
36
  this.queries = queries;
30
37
  this.authService = authService;
38
+ this.eventEmitter = eventEmitter;
31
39
  }
32
40
  get owner() {
33
41
  return this.session.userIdEncoded;
@@ -46,6 +54,10 @@ export class ContactService {
46
54
  return updateContactName(this.owner, contactUserId, newName, this.queries);
47
55
  }
48
56
  async delete(contactUserId) {
49
- return deleteContact(this.owner, contactUserId, this.session, this.queries);
57
+ const result = await deleteContact(this.owner, contactUserId, this.session, this.queries);
58
+ if (result.success) {
59
+ this.eventEmitter.emit(SdkEventType.CONTACT_DELETED, { contactUserId });
60
+ }
61
+ return result;
50
62
  }
51
63
  }
@@ -11,7 +11,6 @@ import { SessionModule } from '../wasm/index.js';
11
11
  import { Result } from '../utils/type.js';
12
12
  import { SdkConfig } from '../config/sdk.js';
13
13
  import { SdkEventEmitter } from '../core/SdkEventEmitter.js';
14
- import type { RefreshService } from './refresh.js';
15
14
  import { Queries } from '../db/queries/index.js';
16
15
  import { QueueManager } from '../utils/queue.js';
17
16
  import { GossipSqliteTx } from '../db/sqlite.js';
@@ -41,7 +40,6 @@ export declare class MessageService {
41
40
  private session;
42
41
  private eventEmitter;
43
42
  private config;
44
- private refreshService?;
45
43
  private queueManager?;
46
44
  private processingContacts;
47
45
  private isFetchingMessages;
@@ -57,7 +55,6 @@ export declare class MessageService {
57
55
  /** Emit MESSAGE_RECEIVED with a Message that may not have a DB id yet */
58
56
  private emitMessageReceived;
59
57
  constructor(messageProtocol: IMessageProtocol, session: SessionModule, eventEmitter: SdkEventEmitter, config: SdkConfig | undefined, queries: Queries);
60
- setRefreshService(refreshService: RefreshService): void;
61
58
  setQueueManager(queueManager: QueueManager): void;
62
59
  fetchMessages(): Promise<MessageResult>;
63
60
  /**
@@ -175,12 +175,6 @@ export class MessageService {
175
175
  writable: true,
176
176
  value: void 0
177
177
  });
178
- Object.defineProperty(this, "refreshService", {
179
- enumerable: true,
180
- configurable: true,
181
- writable: true,
182
- value: void 0
183
- });
184
178
  Object.defineProperty(this, "queueManager", {
185
179
  enumerable: true,
186
180
  configurable: true,
@@ -224,9 +218,6 @@ export class MessageService {
224
218
  this.config = config;
225
219
  this.queries = queries;
226
220
  }
227
- setRefreshService(refreshService) {
228
- this.refreshService = refreshService;
229
- }
230
221
  setQueueManager(queueManager) {
231
222
  this.queueManager = queueManager;
232
223
  }
@@ -313,8 +304,7 @@ export class MessageService {
313
304
  * Add a message to SQLite and update the corresponding discussion.
314
305
  */
315
306
  async addMessageAndUpdateDiscussion(message, parentTx) {
316
- const db = parentTx ?? this.queries.conn.db;
317
- const result = await db.transaction(async (tx) => {
307
+ const result = await (parentTx ?? this.queries.conn.db).transaction(async (tx) => {
318
308
  const messageId = await this.queries.messages.insert({
319
309
  messageId: message.messageId,
320
310
  ownerUserId: message.ownerUserId,
@@ -336,7 +326,9 @@ export class MessageService {
336
326
  whenToSend: message.whenToSend,
337
327
  }, tx);
338
328
  const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, tx);
339
- if (discussion && POST_MESSAGE_TYPES.includes(message.type)) {
329
+ if (discussion &&
330
+ POST_MESSAGE_TYPES.includes(message.type) &&
331
+ !message.editOf) {
340
332
  await this.queries.discussions.updateById(discussion.id, {
341
333
  lastMessageId: messageId,
342
334
  lastMessageContent: message.content,
@@ -350,7 +342,7 @@ export class MessageService {
350
342
  }
351
343
  return { messageId, updatedDiscussionId: null };
352
344
  });
353
- if (result.updatedDiscussionId) {
345
+ if (result.updatedDiscussionId && !parentTx) {
354
346
  this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, result.updatedDiscussionId);
355
347
  }
356
348
  return result.messageId;
@@ -672,6 +664,17 @@ export class MessageService {
672
664
  error: 'Failed to add message to database, got error: ' + error,
673
665
  };
674
666
  }
667
+ if (parentTx) {
668
+ // When called inside an existing SQL transaction, avoid lock re-entry
669
+ // (queue processing + plain read paths run through conn.db/execRaw queue).
670
+ return {
671
+ success: true,
672
+ message: {
673
+ ...message,
674
+ id: messageIdDb,
675
+ },
676
+ };
677
+ }
675
678
  /*
676
679
  Trigger a sending queue state update for contact in order to send the new message.
677
680
  If the processSendQueueForContact function is already running, it will be skipped.
@@ -1108,26 +1111,28 @@ export class MessageService {
1108
1111
  ...(options?.metadata && { metadata: options.metadata }),
1109
1112
  };
1110
1113
  const result = await this.send(message);
1111
- await this.refreshService?.stateUpdate();
1112
1114
  return result;
1113
1115
  }
1114
1116
  /** Fetch and decrypt messages from the protocol (alias) */
1115
1117
  async fetch() {
1116
1118
  return this.fetchMessages();
1117
1119
  }
1118
- async PerformDeleteMessage(message, tx) {
1120
+ async PerformDeleteMessage(message, parentTx) {
1119
1121
  if (!message.id) {
1120
1122
  return { success: false, error: new Error('Message ID is required') };
1121
1123
  }
1122
- const db = tx ?? this.queries.conn.db;
1123
1124
  if (message.type === MessageType.REACTION) {
1124
1125
  // Reaction delete: hard-delete the row, not "[Message deleted]"
1125
1126
  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 };
1127
+ await this.queries.messages.deleteById(message.id, parentTx);
1128
+ return {
1129
+ success: true,
1130
+ data: () => {
1131
+ this.eventEmitter.emit(SdkEventType.MESSAGE_DELETED, {
1132
+ messages: [message],
1133
+ });
1134
+ },
1135
+ };
1131
1136
  }
1132
1137
  catch (error) {
1133
1138
  return {
@@ -1140,64 +1145,51 @@ export class MessageService {
1140
1145
  }
1141
1146
  let deletedMessages = [];
1142
1147
  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');
1148
+ let discussionUpdated = false;
1149
+ const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId, parentTx);
1150
+ if (!discussion) {
1151
+ return { success: false, error: new Error('Discussion not found') };
1152
+ }
1153
+ await (parentTx ?? this.queries.conn.db).transaction(async (trx) => {
1154
+ // delete the message : MessageType.DELETED '[Message deleted]' in db
1155
+ await this.queries.messages.updateById(message.id, // message.id is guaranteed to be not null because we checked it above
1156
+ {
1157
+ content: '[Message deleted]',
1158
+ type: MessageType.DELETED,
1159
+ }, trx);
1160
+ if (POST_MESSAGE_TYPES.includes(message.type)) {
1161
+ // If the message to delete is the last text message in the discussion, update the discussion to the previous last text message
1162
+ if (discussion.lastMessageId === message.id) {
1163
+ const lastMessage = await this.queries.discussions.getLastTextMessage(message.contactUserId, trx);
1164
+ await this.queries.discussions.updateById(discussion.id, {
1165
+ lastMessageId: lastMessage?.id ?? null,
1166
+ lastMessageContent: lastMessage?.content ?? null,
1167
+ lastMessageTimestamp: lastMessage?.timestamp ?? null,
1168
+ updatedAt: new Date(),
1169
+ }, trx);
1170
+ discussionUpdated = true;
1154
1171
  }
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));
1172
+ // If deleted message is not read yet, decrement the discussion unread count
1173
+ if (message.status !== MessageStatus.READ &&
1174
+ message.direction === MessageDirection.INCOMING) {
1175
+ await this.queries.discussions.decrementUnreadCount(discussion.id, trx);
1176
+ discussionUpdated = true;
1192
1177
  }
1193
- });
1194
- }
1195
- catch (error) {
1196
- return {
1197
- success: false,
1198
- error: error instanceof Error ? error : new Error(String(error)),
1199
- };
1200
- }
1178
+ // Delete all REACTION messages for this contact referencing this message
1179
+ const deletedReactionMessages = await trx
1180
+ .delete(schema.messages)
1181
+ .where(and(eq(schema.messages.contactUserId, message.contactUserId), eq(schema.messages.type, MessageType.REACTION), sql `json_extract(${schema.messages.reactionOf}, '$.originalMsgId') = ${encodeToBase64(message.messageId)}`))
1182
+ .returning();
1183
+ deletedMessages = deletedReactionMessages.map(rowToMessage);
1184
+ // Also update all messages REPLYING to this message by setting their replyTo to null
1185
+ const updatedMessagesDb = await trx
1186
+ .update(schema.messages)
1187
+ .set({ replyTo: null })
1188
+ .where(and(eq(schema.messages.contactUserId, message.contactUserId), sql `json_extract(${schema.messages.replyTo}, '$.originalMsgId') = ${encodeToBase64(message.messageId)}`))
1189
+ .returning();
1190
+ updatedMessages = updatedMessagesDb.map(row => rowToMessage(row));
1191
+ }
1192
+ });
1201
1193
  // function to be called after the db transaction is committed.
1202
1194
  // Send events only when we are sure corresponding operation are saved in db
1203
1195
  const postDbCommit = () => {
@@ -1209,11 +1201,11 @@ export class MessageService {
1209
1201
  messages: updatedMessages,
1210
1202
  });
1211
1203
  }
1212
- if (discussionId) {
1213
- this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionId);
1204
+ if (discussionUpdated) {
1205
+ this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussion.id);
1214
1206
  }
1215
1207
  };
1216
- if (!tx) {
1208
+ if (!parentTx) {
1217
1209
  // if we are not in a db transaction, we can just emit the event and return
1218
1210
  postDbCommit();
1219
1211
  return { success: true, data: null };
@@ -1237,7 +1229,7 @@ export class MessageService {
1237
1229
  if (!row.messageId)
1238
1230
  throw new Error('Cannot delete a message that has no messageId');
1239
1231
  const ownerUserId = this.session.userIdEncoded;
1240
- const callbackAfterDbCommit = await this.queries.conn.db.transaction(async (tx) => {
1232
+ const callbackAfterDbCommit = await this.queries.conn.withTransaction(async (tx) => {
1241
1233
  const res = await this.PerformDeleteMessage(rowToMessage(row), tx);
1242
1234
  if (!res.success) {
1243
1235
  tx.rollback(); // if deleting the message from the db fails, rollback the transaction
@@ -1260,10 +1252,11 @@ export class MessageService {
1260
1252
  throw new Error(result.error ?? 'Failed to enqueue delete message');
1261
1253
  }
1262
1254
  return res.data;
1263
- });
1255
+ }, 'immediate');
1264
1256
  if (callbackAfterDbCommit) {
1265
1257
  callbackAfterDbCommit();
1266
1258
  }
1259
+ await this.processSendQueueForContact(row.contactUserId);
1267
1260
  return true;
1268
1261
  }
1269
1262
  async sendReaction(contactUserId, emoji, originalMsgId) {
@@ -1341,7 +1334,7 @@ export class MessageService {
1341
1334
  const ownerUserId = this.session.userIdEncoded;
1342
1335
  const existingMetadata = deserializeMetadata(row.metadata) ?? {};
1343
1336
  const mergedMetadata = { ...existingMetadata, edited: true };
1344
- const callbackAfterDbCommit = await this.queries.conn.db.transaction(async (tx) => {
1337
+ const callbackAfterDbCommit = await this.queries.conn.withTransaction(async (tx) => {
1345
1338
  const res = await this.performEditMessage(newContent, rowToMessage(row), mergedMetadata, tx);
1346
1339
  if (!res.success) {
1347
1340
  tx.rollback();
@@ -1364,10 +1357,11 @@ export class MessageService {
1364
1357
  throw new Error(result.error ?? 'Failed to enqueue edit message');
1365
1358
  }
1366
1359
  return res.data;
1367
- });
1360
+ }, 'immediate');
1368
1361
  if (callbackAfterDbCommit) {
1369
1362
  callbackAfterDbCommit();
1370
1363
  }
1364
+ await this.processSendQueueForContact(row.contactUserId);
1371
1365
  return true;
1372
1366
  }
1373
1367
  /**
@@ -1380,7 +1374,15 @@ export class MessageService {
1380
1374
  const withRetention = allRows.filter(d => d.messageRetentionDuration != null && d.messageRetentionDuration > 0);
1381
1375
  if (withRetention.length === 0)
1382
1376
  return;
1383
- await this.queries.messages.deleteExpiredByOwner(ownerUserId, withRetention);
1377
+ const expiredRows = await this.queries.messages.getExpiredByOwner(ownerUserId, withRetention);
1378
+ if (expiredRows.length === 0)
1379
+ return;
1380
+ await Promise.all(expiredRows.map(async (row) => {
1381
+ const result = await this.PerformDeleteMessage(rowToMessage(row));
1382
+ if (!result.success) {
1383
+ throw result.error ?? new Error('Failed to delete expired message');
1384
+ }
1385
+ }));
1384
1386
  }
1385
1387
  // 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.
1386
1388
  async markAsRead(id) {
@@ -1392,7 +1394,7 @@ export class MessageService {
1392
1394
  }
1393
1395
  const message = rowToMessage(row);
1394
1396
  // Perform message status update and unread count decrement atomically in a transaction
1395
- const discussionId = await this.queries.conn.db.transaction(async (tx) => {
1397
+ const discussionId = await this.queries.conn.withTransaction(async (tx) => {
1396
1398
  // Update message status
1397
1399
  await this.queries.messages.updateById(id, { status: MessageStatus.READ }, tx);
1398
1400
  // Atomically decrement discussion unread count
@@ -1404,7 +1406,7 @@ export class MessageService {
1404
1406
  await this.queries.discussions.decrementUnreadCount(discussion.id, tx);
1405
1407
  }
1406
1408
  return discussion.id;
1407
- });
1409
+ }, 'immediate');
1408
1410
  this.eventEmitter.emit(SdkEventType.MESSAGE_READ, id);
1409
1411
  this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussionId);
1410
1412
  return true;
@@ -78,10 +78,10 @@ export async function deleteContact(ownerUserId, contactUserId, session, queries
78
78
  };
79
79
  }
80
80
  // Delete contact, discussions, and messages atomically
81
- await queries.conn.withTransaction(async () => {
82
- await queries.contacts.deleteByOwnerAndUser(ownerUserId, contactUserId);
83
- await queries.discussions.deleteByOwnerAndContact(ownerUserId, contactUserId);
84
- await queries.messages.deleteByOwnerAndContact(ownerUserId, contactUserId);
81
+ await queries.conn.withTransaction(async (tx) => {
82
+ await queries.contacts.deleteByOwnerAndUser(ownerUserId, contactUserId, tx);
83
+ await queries.discussions.deleteByOwnerAndContact(ownerUserId, contactUserId, tx);
84
+ await queries.messages.deleteByOwnerAndContact(ownerUserId, contactUserId, tx);
85
85
  });
86
86
  // Discard peer from session manager (WASM state, outside transaction)
87
87
  await session.peerDiscard(decodeUserId(contactUserId));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/gossip-sdk",
3
- "version": "0.0.2-dev.20260428144322",
3
+ "version": "0.0.2-dev.20260429045217",
4
4
  "description": "Gossip SDK for automation, chatbot, and integration use cases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",