diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 84a9e2c6e3..6411a43d04 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -221,6 +221,7 @@ const dataInterface: ClientInterface = { markReactionAsRead, removeReactionFromConversation, addReaction, + _getAllReactions, getMessageBySender, getMessageById, @@ -1258,6 +1259,10 @@ async function addReaction(reactionObj: ReactionType) { return channels.addReaction(reactionObj); } +async function _getAllReactions() { + return channels._getAllReactions(); +} + function handleMessageJSON(messages: Array) { return messages.map(message => JSON.parse(message.json)); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 0ea94adf33..3cc29e26ad 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -389,6 +389,7 @@ export type DataInterface = { targetTimestamp: number; }) => Promise; addReaction: (reactionObj: ReactionType) => Promise; + _getAllReactions: () => Promise>; getUnprocessedCount: () => Promise; getAllUnprocessed: () => Promise>; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 8f2463366c..067da9ba09 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -203,6 +203,7 @@ const dataInterface: ServerInterface = { markReactionAsRead, addReaction, removeReactionFromConversation, + _getAllReactions, getMessageBySender, getMessageById, getMessagesById, @@ -2530,6 +2531,71 @@ function updateToSchemaVersion41(currentVersion: number, db: Database) { logger.info('updateToSchemaVersion41: success!'); } +function updateToSchemaVersion42(currentVersion: number, db: Database) { + if (currentVersion >= 42) { + return; + } + + db.transaction(() => { + // First, recreate messages table delete trigger with reaction support + + db.exec(` + DROP TRIGGER messages_on_delete; + + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + DELETE FROM sendLogPayloads WHERE id IN ( + SELECT payloadId FROM sendLogMessageIds + WHERE messageId = old.id + ); + DELETE FROM reactions WHERE rowid IN ( + SELECT rowid FROM reactions + WHERE messageId = old.id + ); + END; + `); + + // Then, delete previously-orphaned reactions + + // Note: we use `pluck` here to fetch only the first column of + // returned row. + const messageIdList: Array = db + .prepare('SELECT id FROM messages ORDER BY id ASC;') + .pluck() + .all(); + const allReactions: Array<{ + rowid: number; + messageId: string; + }> = db.prepare('SELECT rowid, messageId FROM reactions;').all(); + + const messageIds = new Set(messageIdList); + const reactionsToDelete: Array = []; + + allReactions.forEach(reaction => { + if (!messageIds.has(reaction.messageId)) { + reactionsToDelete.push(reaction.rowid); + } + }); + + function deleteReactions(rowids: Array) { + db.prepare( + ` + DELETE FROM reactions + WHERE rowid IN ( ${rowids.map(() => '?').join(', ')} ); + ` + ).run(rowids); + } + + if (reactionsToDelete.length > 0) { + logger.info(`Deleting ${reactionsToDelete.length} orphaned reactions`); + batchMultiVarQuery(reactionsToDelete, deleteReactions, db); + } + + db.pragma('user_version = 42'); + })(); + logger.info('updateToSchemaVersion42: success!'); +} + export const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -2572,6 +2638,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion39, updateToSchemaVersion40, updateToSchemaVersion41, + updateToSchemaVersion42, ]; export function updateSchema(db: Database) { @@ -2809,19 +2876,22 @@ function getInstance(): Database { function batchMultiVarQuery( values: Array, - query: (batch: Array) => void + query: (batch: Array) => void, + providedDatabase?: Database ): []; function batchMultiVarQuery( values: Array, - query: (batch: Array) => Array + query: (batch: Array) => Array, + providedDatabase?: Database ): Array; function batchMultiVarQuery( values: Array, query: | ((batch: Array) => void) - | ((batch: Array) => Array) + | ((batch: Array) => Array), + providedDatabase?: Database ): Array { - const db = getInstance(); + const db = providedDatabase || getInstance(); if (values.length > MAX_VARIABLE_COUNT) { const result: Array = []; db.transaction(() => { @@ -4608,6 +4678,11 @@ async function removeReactionFromConversation({ }); } +async function _getAllReactions(): Promise> { + const db = getInstance(); + return db.prepare('SELECT * from reactions;').all(); +} + async function getOlderMessagesByConversation( conversationId: string, { diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index 2110b86e7e..afb1581f4e 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -7,10 +7,6 @@ import { v4 as generateGuid } from 'uuid'; import { SCHEMA_VERSIONS } from '../sql/Server'; -const THEIR_UUID = generateGuid(); -const THEIR_CONVO = generateGuid(); -const ANOTHER_CONVO = generateGuid(); -const THIRD_CONVO = generateGuid(); const OUR_UUID = generateGuid(); describe('SQL migrations test', () => { @@ -87,6 +83,11 @@ describe('SQL migrations test', () => { }); describe('updateToSchemaVersion41', () => { + const THEIR_UUID = generateGuid(); + const THEIR_CONVO = generateGuid(); + const ANOTHER_CONVO = generateGuid(); + const THIRD_CONVO = generateGuid(); + it('clears sessions and keys if UUID is not available', () => { updateToVersion(40); @@ -519,4 +520,99 @@ describe('SQL migrations test', () => { ); }); }); + + describe('updateToSchemaVersion42', () => { + const MESSAGE_ID_1 = generateGuid(); + const MESSAGE_ID_2 = generateGuid(); + const MESSAGE_ID_3 = generateGuid(); + const MESSAGE_ID_4 = generateGuid(); + const CONVERSATION_ID = generateGuid(); + + it('deletes orphaned reactions', () => { + updateToVersion(41); + + db.exec( + ` + INSERT INTO messages + (id, conversationId, body) + VALUES + ('${MESSAGE_ID_1}', '${CONVERSATION_ID}', 'message number 1'), + ('${MESSAGE_ID_2}', '${CONVERSATION_ID}', 'message number 2'); + INSERT INTO reactions (messageId, conversationId) VALUES + ('${MESSAGE_ID_1}', '${CONVERSATION_ID}'), + ('${MESSAGE_ID_2}', '${CONVERSATION_ID}'), + ('${MESSAGE_ID_3}', '${CONVERSATION_ID}'), + ('${MESSAGE_ID_4}', '${CONVERSATION_ID}'); + ` + ); + + const reactionCount = db + .prepare('SELECT COUNT(*) FROM reactions;') + .pluck(); + const messageCount = db.prepare('SELECT COUNT(*) FROM messages;').pluck(); + + assert.strictEqual(reactionCount.get(), 4); + assert.strictEqual(messageCount.get(), 2); + + updateToVersion(42); + + assert.strictEqual(reactionCount.get(), 2); + assert.strictEqual(messageCount.get(), 2); + + const reactionMessageIds = db + .prepare('SELECT messageId FROM reactions;') + .pluck() + .all(); + + assert.sameDeepMembers(reactionMessageIds, [MESSAGE_ID_1, MESSAGE_ID_2]); + }); + + it('new message delete trigger deletes reactions as well', () => { + updateToVersion(41); + + db.exec( + ` + INSERT INTO messages + (id, conversationId, body) + VALUES + ('${MESSAGE_ID_1}', '${CONVERSATION_ID}', 'message number 1'), + ('${MESSAGE_ID_2}', '${CONVERSATION_ID}', 'message number 2'), + ('${MESSAGE_ID_3}', '${CONVERSATION_ID}', 'message number 3'); + INSERT INTO reactions (messageId, conversationId) VALUES + ('${MESSAGE_ID_1}', '${CONVERSATION_ID}'), + ('${MESSAGE_ID_2}', '${CONVERSATION_ID}'), + ('${MESSAGE_ID_3}', '${CONVERSATION_ID}'); + ` + ); + + const reactionCount = db + .prepare('SELECT COUNT(*) FROM reactions;') + .pluck(); + const messageCount = db.prepare('SELECT COUNT(*) FROM messages;').pluck(); + + assert.strictEqual(reactionCount.get(), 3); + assert.strictEqual(messageCount.get(), 3); + + updateToVersion(42); + + assert.strictEqual(reactionCount.get(), 3); + assert.strictEqual(messageCount.get(), 3); + + db.exec( + ` + DELETE FROM messages WHERE id = '${MESSAGE_ID_1}'; + ` + ); + + assert.strictEqual(reactionCount.get(), 2); + assert.strictEqual(messageCount.get(), 2); + + const reactionMessageIds = db + .prepare('SELECT messageId FROM reactions;') + .pluck() + .all(); + + assert.sameDeepMembers(reactionMessageIds, [MESSAGE_ID_2, MESSAGE_ID_3]); + }); + }); });