@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.
@@ -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
+ });