@onyx-p/imlib-web 3.0.1 → 3.0.2
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/README.md +108 -5
- package/database/main.cjs +341 -0
- package/database/preload.cjs +45 -0
- package/database/worker.cjs +592 -0
- package/index.esm.js +4 -4
- package/index.umd.js +4 -4
- package/package.json +28 -2
- package/types/database/contracts.d.ts +44 -0
- package/types/database/main/index.d.ts +2 -0
- package/types/database/main/ipc.d.ts +18 -0
- package/types/database/main/keyStore.d.ts +14 -0
- package/types/database/main/messageStore.d.ts +58 -0
- package/types/database/main/migrations.d.ts +9 -0
- package/types/database/main/service.d.ts +36 -0
- package/types/database/main/sqlTypes.d.ts +2 -0
- package/types/database/main/worker.d.ts +1 -0
- package/types/database/main/workerProtocol.d.ts +34 -0
- package/types/database/main-entry.d.ts +12 -0
- package/types/database/preload-entry.d.ts +1 -0
- package/types/database/preload.d.ts +8 -0
- package/types/database/renderer/bridge.d.ts +3 -0
- package/types/database/renderer/legacyIndexedDbMigration.d.ts +28 -0
- package/types/database/renderer/messageRepository.d.ts +43 -0
- package/types/index.d.ts +13 -2
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var node_worker_threads = require('node:worker_threads');
|
|
4
|
+
var SQL = require('@signalapp/sqlcipher');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
var SQL__default = /*#__PURE__*/_interopDefault(SQL);
|
|
9
|
+
|
|
10
|
+
const READ_METHOD_NAMES = [
|
|
11
|
+
'getMessages',
|
|
12
|
+
'getLatestMessage',
|
|
13
|
+
'getLatestMessages',
|
|
14
|
+
'getDialogLoadedState',
|
|
15
|
+
'getMessageById',
|
|
16
|
+
'getMessageByUId',
|
|
17
|
+
'getMessageIdRange',
|
|
18
|
+
'searchMessages',
|
|
19
|
+
'searchTextMessages',
|
|
20
|
+
'getLegacyMigrationState'
|
|
21
|
+
];
|
|
22
|
+
const WRITE_METHOD_NAMES = [
|
|
23
|
+
'addMessages',
|
|
24
|
+
'clearConversationCache',
|
|
25
|
+
'clearAllCache',
|
|
26
|
+
'removeMessagesByUId',
|
|
27
|
+
'updateMessageReceiptStatus',
|
|
28
|
+
'clearBurnAfterReadingExpiredMessages',
|
|
29
|
+
'upsertMessage',
|
|
30
|
+
'convertToRecallMessages',
|
|
31
|
+
'importLegacyBatch',
|
|
32
|
+
'completeLegacyMigration'
|
|
33
|
+
];
|
|
34
|
+
const READ_METHODS = new Set(READ_METHOD_NAMES);
|
|
35
|
+
const WRITE_METHODS = new Set(WRITE_METHOD_NAMES);
|
|
36
|
+
function serializeDatabaseError(error) {
|
|
37
|
+
if (error instanceof Error) {
|
|
38
|
+
return {
|
|
39
|
+
name: error.name,
|
|
40
|
+
message: error.message,
|
|
41
|
+
stack: error.stack
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name: 'Error',
|
|
46
|
+
message: String(error),
|
|
47
|
+
stack: undefined
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SCHEMA_MIGRATIONS = [
|
|
52
|
+
{
|
|
53
|
+
version: 1,
|
|
54
|
+
up(database) {
|
|
55
|
+
database.exec(`
|
|
56
|
+
CREATE TABLE metadata (
|
|
57
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
58
|
+
value TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE messages (
|
|
62
|
+
messageId INTEGER PRIMARY KEY NOT NULL,
|
|
63
|
+
messageUId TEXT UNIQUE,
|
|
64
|
+
dialogId TEXT NOT NULL,
|
|
65
|
+
sentTime INTEGER NOT NULL,
|
|
66
|
+
receivedStatus INTEGER,
|
|
67
|
+
burnAfterReadingFlag INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
burnAfterReadingTime INTEGER,
|
|
69
|
+
messageType INTEGER,
|
|
70
|
+
json TEXT NOT NULL
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE dialog_states (
|
|
74
|
+
dialogId TEXT PRIMARY KEY NOT NULL,
|
|
75
|
+
isEnd INTEGER NOT NULL,
|
|
76
|
+
updateTime INTEGER NOT NULL
|
|
77
|
+
);
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
version: 2,
|
|
83
|
+
up(database) {
|
|
84
|
+
database.exec(`
|
|
85
|
+
CREATE INDEX messages_by_dialog_time
|
|
86
|
+
ON messages (dialogId, sentTime DESC, messageId DESC);
|
|
87
|
+
CREATE INDEX messages_by_dialog
|
|
88
|
+
ON messages (dialogId);
|
|
89
|
+
CREATE INDEX messages_by_receipt
|
|
90
|
+
ON messages (receivedStatus);
|
|
91
|
+
CREATE INDEX messages_by_burn
|
|
92
|
+
ON messages (dialogId, burnAfterReadingFlag, sentTime);
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
version: 3,
|
|
98
|
+
up(database) {
|
|
99
|
+
database.exec(`
|
|
100
|
+
ALTER TABLE messages ADD COLUMN searchText TEXT NOT NULL DEFAULT '';
|
|
101
|
+
|
|
102
|
+
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|
103
|
+
searchText,
|
|
104
|
+
content='messages',
|
|
105
|
+
content_rowid='messageId',
|
|
106
|
+
tokenize='unicode61'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
|
110
|
+
INSERT INTO messages_fts(rowid, searchText)
|
|
111
|
+
VALUES (new.messageId, new.searchText);
|
|
112
|
+
END;
|
|
113
|
+
|
|
114
|
+
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
|
|
115
|
+
INSERT INTO messages_fts(messages_fts, rowid, searchText)
|
|
116
|
+
VALUES ('delete', old.messageId, old.searchText);
|
|
117
|
+
END;
|
|
118
|
+
|
|
119
|
+
CREATE TRIGGER messages_fts_update AFTER UPDATE OF searchText ON messages BEGIN
|
|
120
|
+
INSERT INTO messages_fts(messages_fts, rowid, searchText)
|
|
121
|
+
VALUES ('delete', old.messageId, old.searchText);
|
|
122
|
+
INSERT INTO messages_fts(rowid, searchText)
|
|
123
|
+
VALUES (new.messageId, new.searchText);
|
|
124
|
+
END;
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
version: 4,
|
|
130
|
+
up(database) {
|
|
131
|
+
database.exec(`
|
|
132
|
+
CREATE INDEX messages_by_search_order
|
|
133
|
+
ON messages (sentTime DESC, messageId DESC);
|
|
134
|
+
`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
SCHEMA_MIGRATIONS[SCHEMA_MIGRATIONS.length - 1]?.version ?? 0;
|
|
139
|
+
function validateMigrations(migrations) {
|
|
140
|
+
let previous = 0;
|
|
141
|
+
for (const migration of migrations) {
|
|
142
|
+
if (!Number.isSafeInteger(migration.version) || migration.version <= previous) {
|
|
143
|
+
throw new Error('Schema migration versions must be strictly increasing');
|
|
144
|
+
}
|
|
145
|
+
previous = migration.version;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function runMigrations(database, migrations = SCHEMA_MIGRATIONS) {
|
|
149
|
+
validateMigrations(migrations);
|
|
150
|
+
const currentVersion = Number(database.pragma('user_version', { simple: true }) ?? 0);
|
|
151
|
+
const latestVersion = migrations[migrations.length - 1]?.version ?? 0;
|
|
152
|
+
if (currentVersion > latestVersion) {
|
|
153
|
+
throw new Error(`Database schema version ${currentVersion} is newer than supported version ${latestVersion}`);
|
|
154
|
+
}
|
|
155
|
+
for (const migration of migrations) {
|
|
156
|
+
if (migration.version <= currentVersion) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
database.transaction(() => {
|
|
160
|
+
migration.up(database);
|
|
161
|
+
database.pragma(`user_version = ${migration.version}`);
|
|
162
|
+
})();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const LEGACY_MIGRATION_KEY = 'legacy_indexeddb_migration';
|
|
167
|
+
function parseMessage(row) {
|
|
168
|
+
return row ? JSON.parse(row.json) : null;
|
|
169
|
+
}
|
|
170
|
+
function extractSearchText(message) {
|
|
171
|
+
const content = message.content;
|
|
172
|
+
if (typeof content === 'string') {
|
|
173
|
+
return content;
|
|
174
|
+
}
|
|
175
|
+
if (content && typeof content.content === 'string') {
|
|
176
|
+
return content.content;
|
|
177
|
+
}
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
function normalizeInteger(value, name) {
|
|
181
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
182
|
+
if (!Number.isSafeInteger(number)) {
|
|
183
|
+
throw new Error(`${name} must be a safe integer`);
|
|
184
|
+
}
|
|
185
|
+
return number;
|
|
186
|
+
}
|
|
187
|
+
class MessageStore {
|
|
188
|
+
database;
|
|
189
|
+
constructor(database) {
|
|
190
|
+
this.database = database;
|
|
191
|
+
}
|
|
192
|
+
addMessages(messages, dialogId, isEnd) {
|
|
193
|
+
const upsert = this.database.prepare(`
|
|
194
|
+
INSERT INTO messages (
|
|
195
|
+
messageId, messageUId, dialogId, sentTime, receivedStatus,
|
|
196
|
+
burnAfterReadingFlag, burnAfterReadingTime, messageType, json, searchText
|
|
197
|
+
) VALUES (
|
|
198
|
+
$messageId, $messageUId, $dialogId, $sentTime, $receivedStatus,
|
|
199
|
+
$burnAfterReadingFlag, $burnAfterReadingTime, $messageType, $json, $searchText
|
|
200
|
+
)
|
|
201
|
+
ON CONFLICT(messageId) DO UPDATE SET
|
|
202
|
+
messageUId = excluded.messageUId,
|
|
203
|
+
dialogId = excluded.dialogId,
|
|
204
|
+
sentTime = excluded.sentTime,
|
|
205
|
+
receivedStatus = excluded.receivedStatus,
|
|
206
|
+
burnAfterReadingFlag = excluded.burnAfterReadingFlag,
|
|
207
|
+
burnAfterReadingTime = excluded.burnAfterReadingTime,
|
|
208
|
+
messageType = excluded.messageType,
|
|
209
|
+
json = excluded.json,
|
|
210
|
+
searchText = excluded.searchText;
|
|
211
|
+
`);
|
|
212
|
+
this.database.transaction(() => {
|
|
213
|
+
for (const message of messages) {
|
|
214
|
+
if (message.isPersited === false) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const messageId = normalizeInteger(message.messageId, 'messageId');
|
|
218
|
+
const sentTime = normalizeInteger(message.sentTime, 'sentTime');
|
|
219
|
+
upsert.run({
|
|
220
|
+
messageId,
|
|
221
|
+
messageUId: message.messageUId ?? null,
|
|
222
|
+
dialogId,
|
|
223
|
+
sentTime,
|
|
224
|
+
receivedStatus: message.receivedStatus ?? null,
|
|
225
|
+
burnAfterReadingFlag: message.burnAfterReadingFlag ? 1 : 0,
|
|
226
|
+
burnAfterReadingTime: message.burnAfterReadingTime ?? null,
|
|
227
|
+
messageType: message.messageType ?? null,
|
|
228
|
+
json: JSON.stringify(message),
|
|
229
|
+
searchText: extractSearchText(message)
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (isEnd !== undefined) {
|
|
233
|
+
this.setDialogState(dialogId, isEnd);
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
}
|
|
237
|
+
upsertMessage(message, dialogId) {
|
|
238
|
+
this.addMessages([message], dialogId);
|
|
239
|
+
}
|
|
240
|
+
getMessages(dialogId, timestamp = '0', count = 20, isForward = true) {
|
|
241
|
+
const safeCount = Math.max(0, Math.floor(count));
|
|
242
|
+
const params = {
|
|
243
|
+
dialogId,
|
|
244
|
+
limit: safeCount + 1
|
|
245
|
+
};
|
|
246
|
+
let timeClause = '';
|
|
247
|
+
if (timestamp !== '0') {
|
|
248
|
+
params.timestamp = normalizeInteger(timestamp, 'timestamp');
|
|
249
|
+
timeClause = isForward ? 'AND sentTime < $timestamp' : 'AND sentTime > $timestamp';
|
|
250
|
+
}
|
|
251
|
+
const direction = isForward ? 'DESC' : 'ASC';
|
|
252
|
+
const rows = this.database
|
|
253
|
+
.prepare(`SELECT json FROM messages
|
|
254
|
+
WHERE dialogId = $dialogId ${timeClause}
|
|
255
|
+
ORDER BY sentTime ${direction}, messageId ${direction}
|
|
256
|
+
LIMIT $limit;`)
|
|
257
|
+
.all(params);
|
|
258
|
+
const hasAdditionalRow = rows.length > safeCount;
|
|
259
|
+
const messages = rows
|
|
260
|
+
.slice(0, safeCount)
|
|
261
|
+
.map(row => parseMessage(row))
|
|
262
|
+
.sort((left, right) => {
|
|
263
|
+
const byTime = Number(left.sentTime) - Number(right.sentTime);
|
|
264
|
+
return byTime || left.messageId - right.messageId;
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
messages,
|
|
268
|
+
hasMore: hasAdditionalRow || !this.getDialogLoadedState(dialogId)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
getLatestMessage(dialogId) {
|
|
272
|
+
return parseMessage(this.database
|
|
273
|
+
.prepare(`SELECT json FROM messages
|
|
274
|
+
WHERE dialogId = $dialogId
|
|
275
|
+
ORDER BY sentTime DESC, messageId DESC
|
|
276
|
+
LIMIT 1;`)
|
|
277
|
+
.get({ dialogId }));
|
|
278
|
+
}
|
|
279
|
+
getLatestMessages(dialogIds) {
|
|
280
|
+
const result = Object.create(null);
|
|
281
|
+
for (const dialogId of dialogIds) {
|
|
282
|
+
result[dialogId] = null;
|
|
283
|
+
}
|
|
284
|
+
if (dialogIds.length === 0) {
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
const placeholders = dialogIds.map(() => '?').join(', ');
|
|
288
|
+
const rows = this.database
|
|
289
|
+
.prepare(`SELECT dialogId, json FROM (
|
|
290
|
+
SELECT dialogId, json,
|
|
291
|
+
ROW_NUMBER() OVER (
|
|
292
|
+
PARTITION BY dialogId ORDER BY sentTime DESC, messageId DESC
|
|
293
|
+
) AS position
|
|
294
|
+
FROM messages
|
|
295
|
+
WHERE dialogId IN (${placeholders})
|
|
296
|
+
) WHERE position = 1;`)
|
|
297
|
+
.all(dialogIds);
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
result[row.dialogId] = parseMessage(row);
|
|
300
|
+
}
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
getMessageById(messageId) {
|
|
304
|
+
return parseMessage(this.database
|
|
305
|
+
.prepare('SELECT json FROM messages WHERE messageId = $messageId;')
|
|
306
|
+
.get({ messageId }));
|
|
307
|
+
}
|
|
308
|
+
getMessageByUId(messageUId) {
|
|
309
|
+
return parseMessage(this.database
|
|
310
|
+
.prepare('SELECT json FROM messages WHERE messageUId = $messageUId;')
|
|
311
|
+
.get({ messageUId }));
|
|
312
|
+
}
|
|
313
|
+
getMessageIdRange() {
|
|
314
|
+
const row = this.database
|
|
315
|
+
.prepare('SELECT MIN(messageId) AS oldest, MAX(messageId) AS newest FROM messages;')
|
|
316
|
+
.get();
|
|
317
|
+
if (row?.oldest == null || row.newest == null) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
return { oldest: row.oldest, newest: row.newest };
|
|
321
|
+
}
|
|
322
|
+
getDialogLoadedState(dialogId) {
|
|
323
|
+
return (this.database
|
|
324
|
+
.prepare('SELECT isEnd FROM dialog_states WHERE dialogId = $dialogId;', {
|
|
325
|
+
pluck: true
|
|
326
|
+
})
|
|
327
|
+
.get({ dialogId }) === 1);
|
|
328
|
+
}
|
|
329
|
+
clearConversationCache(dialogId) {
|
|
330
|
+
this.database.transaction(() => {
|
|
331
|
+
this.database.prepare('DELETE FROM messages WHERE dialogId = $dialogId;').run({ dialogId });
|
|
332
|
+
this.setDialogState(dialogId, true);
|
|
333
|
+
})();
|
|
334
|
+
}
|
|
335
|
+
clearAllCache() {
|
|
336
|
+
this.database.transaction(() => {
|
|
337
|
+
this.database.exec('DELETE FROM messages; DELETE FROM dialog_states;');
|
|
338
|
+
})();
|
|
339
|
+
}
|
|
340
|
+
removeMessagesByUId(messageUIds) {
|
|
341
|
+
const remove = this.database.prepare('DELETE FROM messages WHERE messageUId = $messageUId;');
|
|
342
|
+
this.database.transaction(() => {
|
|
343
|
+
for (const messageUId of messageUIds) {
|
|
344
|
+
remove.run({ messageUId });
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
}
|
|
348
|
+
updateMessageReceiptStatus(messageUIds, receivedStatus) {
|
|
349
|
+
const select = this.database.prepare('SELECT json, receivedStatus FROM messages WHERE messageUId = $messageUId;');
|
|
350
|
+
const update = this.database.prepare(`
|
|
351
|
+
UPDATE messages SET receivedStatus = $receivedStatus, json = $json
|
|
352
|
+
WHERE messageUId = $messageUId;
|
|
353
|
+
`);
|
|
354
|
+
this.database.transaction(() => {
|
|
355
|
+
for (const messageUId of messageUIds) {
|
|
356
|
+
const row = select.get({ messageUId });
|
|
357
|
+
if (!row || (row.receivedStatus ?? -1) >= receivedStatus) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const message = JSON.parse(row.json);
|
|
361
|
+
message.receivedStatus = receivedStatus;
|
|
362
|
+
update.run({ messageUId, receivedStatus, json: JSON.stringify(message) });
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
365
|
+
}
|
|
366
|
+
clearBurnAfterReadingExpiredMessages(dialogId, now = Date.now()) {
|
|
367
|
+
const rows = this.database
|
|
368
|
+
.prepare(`SELECT messageUId FROM messages
|
|
369
|
+
WHERE dialogId = $dialogId
|
|
370
|
+
AND burnAfterReadingFlag = 1
|
|
371
|
+
AND (burnAfterReadingTime IS NULL OR burnAfterReadingTime = 0
|
|
372
|
+
OR sentTime + burnAfterReadingTime <= $now);`, { pluck: true })
|
|
373
|
+
.all({ dialogId, now });
|
|
374
|
+
this.database
|
|
375
|
+
.prepare(`DELETE FROM messages
|
|
376
|
+
WHERE dialogId = $dialogId
|
|
377
|
+
AND burnAfterReadingFlag = 1
|
|
378
|
+
AND (burnAfterReadingTime IS NULL OR burnAfterReadingTime = 0
|
|
379
|
+
OR sentTime + burnAfterReadingTime <= $now);`)
|
|
380
|
+
.run({ dialogId, now });
|
|
381
|
+
return rows;
|
|
382
|
+
}
|
|
383
|
+
convertToRecallMessages(messageUIds, recallMessageType) {
|
|
384
|
+
const select = this.database.prepare('SELECT json FROM messages WHERE messageUId = $messageUId;');
|
|
385
|
+
const update = this.database.prepare(`
|
|
386
|
+
UPDATE messages SET messageType = $messageType, json = $json, searchText = ''
|
|
387
|
+
WHERE messageUId = $messageUId;
|
|
388
|
+
`);
|
|
389
|
+
this.database.transaction(() => {
|
|
390
|
+
for (const messageUId of messageUIds) {
|
|
391
|
+
const row = select.get({ messageUId });
|
|
392
|
+
const message = parseMessage(row);
|
|
393
|
+
if (!message) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
message.messageType = recallMessageType;
|
|
397
|
+
message.content = { messageUId };
|
|
398
|
+
message.isPersited = true;
|
|
399
|
+
message.isCounted = false;
|
|
400
|
+
message.isMentioned = false;
|
|
401
|
+
message.disableNotification = true;
|
|
402
|
+
update.run({
|
|
403
|
+
messageUId,
|
|
404
|
+
messageType: recallMessageType,
|
|
405
|
+
json: JSON.stringify(message)
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
}
|
|
410
|
+
searchTextMessages(dialogId, keyword) {
|
|
411
|
+
return this.searchMessages(keyword, dialogId, Number.MAX_SAFE_INTEGER);
|
|
412
|
+
}
|
|
413
|
+
searchMessages(keyword, dialogId, limit = dialogId === undefined ? 500 : 100) {
|
|
414
|
+
const query = keyword.trim();
|
|
415
|
+
if (!query) {
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0;
|
|
419
|
+
if (safeLimit === 0) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
const dialogClause = dialogId === undefined ? '' : 'AND messages.dialogId = $dialogId';
|
|
423
|
+
const containsHanText = /[\u3400-\u9fff\uf900-\ufaff]/u.test(query);
|
|
424
|
+
const searchClause = containsHanText
|
|
425
|
+
? 'instr(lower(messages.searchText), lower($plainQuery)) > 0'
|
|
426
|
+
: `messages.messageId IN (
|
|
427
|
+
SELECT rowid FROM messages_fts WHERE messages_fts MATCH $ftsQuery
|
|
428
|
+
)`;
|
|
429
|
+
const ftsQuery = containsHanText
|
|
430
|
+
? ''
|
|
431
|
+
: query
|
|
432
|
+
.split(/\s+/u)
|
|
433
|
+
.map(token => `"${token.replace(/"/g, '""')}"*`)
|
|
434
|
+
.join(' ');
|
|
435
|
+
return this.database
|
|
436
|
+
.prepare(`SELECT messages.json
|
|
437
|
+
FROM messages
|
|
438
|
+
WHERE ${searchClause}
|
|
439
|
+
${dialogClause}
|
|
440
|
+
ORDER BY messages.sentTime DESC, messages.messageId DESC
|
|
441
|
+
LIMIT $limit;`)
|
|
442
|
+
.all({
|
|
443
|
+
ftsQuery,
|
|
444
|
+
plainQuery: query,
|
|
445
|
+
dialogId: dialogId ?? '',
|
|
446
|
+
limit: safeLimit
|
|
447
|
+
})
|
|
448
|
+
.map(row => parseMessage(row));
|
|
449
|
+
}
|
|
450
|
+
getLegacyMigrationState() {
|
|
451
|
+
const value = this.database
|
|
452
|
+
.prepare('SELECT value FROM metadata WHERE key = $key;', { pluck: true })
|
|
453
|
+
.get({ key: LEGACY_MIGRATION_KEY });
|
|
454
|
+
if (value === 'in_progress' || value === 'complete') {
|
|
455
|
+
return value;
|
|
456
|
+
}
|
|
457
|
+
return 'pending';
|
|
458
|
+
}
|
|
459
|
+
importLegacyBatch(batch) {
|
|
460
|
+
this.database.transaction(() => {
|
|
461
|
+
this.setMetadata(LEGACY_MIGRATION_KEY, 'in_progress');
|
|
462
|
+
const byDialog = new Map();
|
|
463
|
+
for (const message of batch.messages) {
|
|
464
|
+
const dialogId = message.dialogId;
|
|
465
|
+
if (!dialogId) {
|
|
466
|
+
throw new Error('Legacy message is missing dialogId');
|
|
467
|
+
}
|
|
468
|
+
const group = byDialog.get(dialogId) ?? [];
|
|
469
|
+
group.push(message);
|
|
470
|
+
byDialog.set(dialogId, group);
|
|
471
|
+
}
|
|
472
|
+
for (const [dialogId, messages] of byDialog) {
|
|
473
|
+
this.addMessages(messages, dialogId);
|
|
474
|
+
}
|
|
475
|
+
for (const state of batch.dialogStates) {
|
|
476
|
+
this.setDialogState(state.dialogId, state.isEnd, state.updateTime);
|
|
477
|
+
}
|
|
478
|
+
})();
|
|
479
|
+
}
|
|
480
|
+
completeLegacyMigration() {
|
|
481
|
+
this.setMetadata(LEGACY_MIGRATION_KEY, 'complete');
|
|
482
|
+
}
|
|
483
|
+
setDialogState(dialogId, isEnd, updateTime = Date.now()) {
|
|
484
|
+
this.database
|
|
485
|
+
.prepare(`
|
|
486
|
+
INSERT INTO dialog_states (dialogId, isEnd, updateTime)
|
|
487
|
+
VALUES ($dialogId, $isEnd, $updateTime)
|
|
488
|
+
ON CONFLICT(dialogId) DO UPDATE SET
|
|
489
|
+
isEnd = excluded.isEnd,
|
|
490
|
+
updateTime = excluded.updateTime;
|
|
491
|
+
`)
|
|
492
|
+
.run({ dialogId, isEnd: isEnd ? 1 : 0, updateTime });
|
|
493
|
+
}
|
|
494
|
+
setMetadata(key, value) {
|
|
495
|
+
this.database
|
|
496
|
+
.prepare(`
|
|
497
|
+
INSERT INTO metadata (key, value) VALUES ($key, $value)
|
|
498
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
|
|
499
|
+
`)
|
|
500
|
+
.run({ key, value });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function openDatabase(dbPath, key, isPrimary) {
|
|
505
|
+
if (!/^[0-9a-f]{64}$/i.test(key)) {
|
|
506
|
+
throw new Error('SQLCipher key must contain exactly 64 hexadecimal characters');
|
|
507
|
+
}
|
|
508
|
+
const database = new SQL__default.default(dbPath, { cacheStatements: true });
|
|
509
|
+
try {
|
|
510
|
+
database.pragma(`key = "x'${key}'"`);
|
|
511
|
+
database.pragma('busy_timeout = 5000');
|
|
512
|
+
database.pragma('foreign_keys = ON');
|
|
513
|
+
if (isPrimary) {
|
|
514
|
+
database.pragma('journal_mode = WAL');
|
|
515
|
+
database.pragma('synchronous = FULL');
|
|
516
|
+
runMigrations(database);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
database.pragma('query_only = ON');
|
|
520
|
+
}
|
|
521
|
+
return database;
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
database.close();
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
class WorkerDispatcher {
|
|
529
|
+
database = null;
|
|
530
|
+
store = null;
|
|
531
|
+
isPrimary = false;
|
|
532
|
+
handle(request) {
|
|
533
|
+
if (request.type === 'init') {
|
|
534
|
+
if (this.database) {
|
|
535
|
+
throw new Error('Database worker is already initialized');
|
|
536
|
+
}
|
|
537
|
+
this.database = openDatabase(request.dbPath, request.key, request.isPrimary);
|
|
538
|
+
this.store = new MessageStore(this.database);
|
|
539
|
+
this.isPrimary = request.isPrimary;
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
if (request.type === 'close') {
|
|
543
|
+
this.database?.close();
|
|
544
|
+
this.database = null;
|
|
545
|
+
this.store = null;
|
|
546
|
+
this.isPrimary = false;
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
if (!this.store) {
|
|
550
|
+
throw new Error('Database worker is not initialized');
|
|
551
|
+
}
|
|
552
|
+
const allowed = request.access === 'read' ? READ_METHODS : WRITE_METHODS;
|
|
553
|
+
if (!allowed.has(request.method)) {
|
|
554
|
+
throw new Error(`${request.method} is not an allowed ${request.access} method`);
|
|
555
|
+
}
|
|
556
|
+
if (request.access === 'write' && !this.isPrimary) {
|
|
557
|
+
throw new Error('Writes are only allowed on the primary database worker');
|
|
558
|
+
}
|
|
559
|
+
const method = this.store[request.method];
|
|
560
|
+
if (typeof method !== 'function') {
|
|
561
|
+
throw new Error(`Database method is not implemented: ${request.method}`);
|
|
562
|
+
}
|
|
563
|
+
return method.apply(this.store, request.args);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!node_worker_threads.parentPort) {
|
|
568
|
+
throw new Error('Database worker must run inside a worker thread');
|
|
569
|
+
}
|
|
570
|
+
const port = node_worker_threads.parentPort;
|
|
571
|
+
const dispatcher = new WorkerDispatcher();
|
|
572
|
+
port.on('message', ({ sequence, request }) => {
|
|
573
|
+
let response;
|
|
574
|
+
try {
|
|
575
|
+
response = {
|
|
576
|
+
sequence,
|
|
577
|
+
ok: true,
|
|
578
|
+
value: dispatcher.handle(request)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
response = {
|
|
583
|
+
sequence,
|
|
584
|
+
ok: false,
|
|
585
|
+
error: serializeDatabaseError(error)
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
port.postMessage(response);
|
|
589
|
+
if (request.type === 'close') {
|
|
590
|
+
port.close();
|
|
591
|
+
}
|
|
592
|
+
});
|