@massalabs/gossip-sdk 0.0.2-dev.20260428144322 → 0.0.2-dev.20260428190031
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/SdkEventEmitter.d.ts +4 -0
- package/dist/core/SdkEventEmitter.js +1 -0
- package/dist/db/migrate.d.ts +2 -2
- package/dist/db/migrate.js +3 -3
- package/dist/db/queries/contacts.d.ts +2 -2
- package/dist/db/queries/contacts.js +2 -2
- package/dist/db/queries/discussions.d.ts +1 -1
- package/dist/db/queries/discussions.js +2 -2
- package/dist/db/queries/messages.d.ts +3 -8
- package/dist/db/queries/messages.js +23 -16
- package/dist/db/queries/sqlBatch.d.ts +81 -0
- package/dist/db/queries/sqlBatch.js +90 -0
- package/dist/db/sqlite.d.ts +5 -1
- package/dist/db/sqlite.js +103 -36
- package/dist/gossip.js +1 -2
- package/dist/services/contact.d.ts +3 -1
- package/dist/services/contact.js +14 -2
- package/dist/services/message.d.ts +0 -3
- package/dist/services/message.js +89 -87
- package/dist/utils/contacts.js +4 -4
- package/package.json +1 -1
|
@@ -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 {
|
package/dist/db/migrate.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/db/migrate.js
CHANGED
|
@@ -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
|
|
30
|
+
await txExecRaw(makeCreateStatementsIdempotent(stmt));
|
|
31
31
|
}
|
|
32
|
-
await
|
|
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)
|
|
79
|
-
|
|
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
|
|
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
|
-
.
|
|
196
|
-
.
|
|
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
|
+
}
|
package/dist/db/sqlite.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
const rows =
|
|
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.
|
|
116
|
-
|
|
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 =>
|
|
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(
|
|
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
|
|
259
|
-
await
|
|
260
|
-
await
|
|
261
|
-
await
|
|
262
|
-
await
|
|
263
|
-
await
|
|
264
|
-
await
|
|
265
|
-
await
|
|
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
|
|
339
|
+
await tx
|
|
273
340
|
.delete(schema.messages)
|
|
274
341
|
.where(eq(schema.messages.ownerUserId, userId));
|
|
275
|
-
await
|
|
342
|
+
await tx
|
|
276
343
|
.delete(schema.discussions)
|
|
277
344
|
.where(eq(schema.discussions.ownerUserId, userId));
|
|
278
|
-
await
|
|
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
|
|
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
|
|
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
|
|
291
|
-
await
|
|
292
|
-
await
|
|
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
|
|
298
|
-
await
|
|
299
|
-
await
|
|
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
|
-
|
|
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>;
|
package/dist/services/contact.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/dist/services/message.js
CHANGED
|
@@ -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
|
|
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 &&
|
|
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,
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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 (
|
|
1213
|
-
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED,
|
|
1204
|
+
if (discussionUpdated) {
|
|
1205
|
+
this.eventEmitter.emit(SdkEventType.DISCUSSION_UPDATED, discussion.id);
|
|
1214
1206
|
}
|
|
1215
1207
|
};
|
|
1216
|
-
if (!
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|
package/dist/utils/contacts.js
CHANGED
|
@@ -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