diff --git a/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js b/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js index 354c9ba..57dd66a 100644 --- a/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js +++ b/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js @@ -1,14 +1,62 @@ "use strict"; +const tableExists = async (queryInterface, tableName) => { + const tables = await queryInterface.showAllTables(); + return tables.some((table) => { + const name = typeof table === "string" ? table : table.tableName || table.name; + return name === tableName; + }); +}; + +const getTableColumns = async (queryInterface, tableName) => { + if (!(await tableExists(queryInterface, tableName))) { + return {}; + } + return queryInterface.describeTable(tableName); +}; + +const addColumnIfMissing = async (queryInterface, tableName, columnName, definition) => { + const columns = await getTableColumns(queryInterface, tableName); + if (!columns[columnName]) { + await queryInterface.addColumn(tableName, columnName, definition); + } +}; + +const createTableIfMissing = async (queryInterface, tableName, definition) => { + if (!(await tableExists(queryInterface, tableName))) { + await queryInterface.createTable(tableName, definition); + } +}; + +const indexExists = async (queryInterface, tableName, indexName) => { + if (!(await tableExists(queryInterface, tableName))) { + return false; + } + const indexes = await queryInterface.showIndex(tableName); + return indexes.some((index) => index.name === indexName); +}; + +const addIndexIfMissing = async (queryInterface, tableName, fields, options) => { + if (!(await indexExists(queryInterface, tableName, options.name))) { + await queryInterface.addIndex(tableName, fields, options); + } +}; + +const removeIndexIfExists = async (queryInterface, tableName, indexName) => { + if (await indexExists(queryInterface, tableName, indexName)) { + await queryInterface.removeIndex(tableName, indexName); + } +}; + module.exports = { up: async (queryInterface, Sequelize) => { - await queryInterface.addColumn("Chats", "securityMode", { + await addColumnIfMissing(queryInterface, "Chats", "securityMode", { type: Sequelize.STRING(32), allowNull: false, defaultValue: "legacy", }); - await queryInterface.addColumn("Chats", "protocolVersion", { + await addColumnIfMissing(queryInterface, "Chats", "protocolVersion", { type: Sequelize.STRING(32), allowNull: true, defaultValue: null, @@ -18,7 +66,7 @@ module.exports = { `UPDATE "Chats" SET "securityMode" = 'support_plain' WHERE "type" = 'support';`, ); - await queryInterface.createTable("UserDevices", { + await createTableIfMissing(queryInterface, "UserDevices", { id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, @@ -77,7 +125,7 @@ module.exports = { }, }); - await queryInterface.createTable("DeviceKeyBundles", { + await createTableIfMissing(queryInterface, "DeviceKeyBundles", { id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, @@ -134,7 +182,7 @@ module.exports = { }, }); - await queryInterface.createTable("DeviceOneTimePreKeys", { + await createTableIfMissing(queryInterface, "DeviceOneTimePreKeys", { id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, @@ -175,42 +223,53 @@ module.exports = { }, }); - await queryInterface.addIndex("Chats", ["securityMode"], { + await addIndexIfMissing(queryInterface, "Chats", ["securityMode"], { name: "idx_chats_security_mode", }); - await queryInterface.addIndex("UserDevices", ["userId", "isActive"], { + await addIndexIfMissing(queryInterface, "UserDevices", ["userId", "isActive"], { name: "idx_user_devices_user_active", }); - await queryInterface.addIndex("UserDevices", ["deviceId"], { + await addIndexIfMissing(queryInterface, "UserDevices", ["deviceId"], { name: "idx_user_devices_device_id", unique: true, }); - await queryInterface.addIndex("DeviceKeyBundles", ["userDeviceId"], { + await addIndexIfMissing(queryInterface, "DeviceKeyBundles", ["userDeviceId"], { name: "idx_device_key_bundles_user_device_id", unique: true, }); - await queryInterface.addIndex("DeviceOneTimePreKeys", ["userDeviceId", "usedAt"], { + await addIndexIfMissing(queryInterface, "DeviceOneTimePreKeys", ["userDeviceId", "usedAt"], { name: "idx_device_one_time_pre_keys_device_used_at", }); - await queryInterface.addIndex("DeviceOneTimePreKeys", ["userDeviceId", "preKeyId"], { + await addIndexIfMissing(queryInterface, "DeviceOneTimePreKeys", ["userDeviceId", "preKeyId"], { name: "idx_device_one_time_pre_keys_device_prekey", unique: true, }); }, down: async (queryInterface) => { - await queryInterface.removeIndex("DeviceOneTimePreKeys", "idx_device_one_time_pre_keys_device_prekey"); - await queryInterface.removeIndex("DeviceOneTimePreKeys", "idx_device_one_time_pre_keys_device_used_at"); - await queryInterface.removeIndex("DeviceKeyBundles", "idx_device_key_bundles_user_device_id"); - await queryInterface.removeIndex("UserDevices", "idx_user_devices_device_id"); - await queryInterface.removeIndex("UserDevices", "idx_user_devices_user_active"); - await queryInterface.removeIndex("Chats", "idx_chats_security_mode"); - - await queryInterface.dropTable("DeviceOneTimePreKeys"); - await queryInterface.dropTable("DeviceKeyBundles"); - await queryInterface.dropTable("UserDevices"); - - await queryInterface.removeColumn("Chats", "protocolVersion"); - await queryInterface.removeColumn("Chats", "securityMode"); + await removeIndexIfExists(queryInterface, "DeviceOneTimePreKeys", "idx_device_one_time_pre_keys_device_prekey"); + await removeIndexIfExists(queryInterface, "DeviceOneTimePreKeys", "idx_device_one_time_pre_keys_device_used_at"); + await removeIndexIfExists(queryInterface, "DeviceKeyBundles", "idx_device_key_bundles_user_device_id"); + await removeIndexIfExists(queryInterface, "UserDevices", "idx_user_devices_device_id"); + await removeIndexIfExists(queryInterface, "UserDevices", "idx_user_devices_user_active"); + await removeIndexIfExists(queryInterface, "Chats", "idx_chats_security_mode"); + + if (await tableExists(queryInterface, "DeviceOneTimePreKeys")) { + await queryInterface.dropTable("DeviceOneTimePreKeys"); + } + if (await tableExists(queryInterface, "DeviceKeyBundles")) { + await queryInterface.dropTable("DeviceKeyBundles"); + } + if (await tableExists(queryInterface, "UserDevices")) { + await queryInterface.dropTable("UserDevices"); + } + + const chatColumns = await getTableColumns(queryInterface, "Chats"); + if (chatColumns.protocolVersion) { + await queryInterface.removeColumn("Chats", "protocolVersion"); + } + if (chatColumns.securityMode) { + await queryInterface.removeColumn("Chats", "securityMode"); + } }, }; diff --git a/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js b/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js index abdbf86..bfaaae4 100644 --- a/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js +++ b/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js @@ -1,83 +1,115 @@ "use strict"; +const tableExists = async (queryInterface, tableName) => { + const tables = await queryInterface.showAllTables(); + return tables.some((table) => { + const name = typeof table === "string" ? table : table.tableName || table.name; + return name === tableName; + }); +}; + +const indexExists = async (queryInterface, tableName, indexName) => { + if (!(await tableExists(queryInterface, tableName))) { + return false; + } + const indexes = await queryInterface.showIndex(tableName); + return indexes.some((index) => index.name === indexName); +}; + +const addIndexIfMissing = async (queryInterface, tableName, fields, options) => { + if (!(await indexExists(queryInterface, tableName, options.name))) { + await queryInterface.addIndex(tableName, fields, options); + } +}; + +const removeIndexIfExists = async (queryInterface, tableName, indexName) => { + if (await indexExists(queryInterface, tableName, indexName)) { + await queryInterface.removeIndex(tableName, indexName); + } +}; + module.exports = { async up(queryInterface, Sequelize) { - await queryInterface.createTable("ChatMessageRecipientPayloads", { - id: { - type: Sequelize.UUID, - defaultValue: Sequelize.UUIDV4, - primaryKey: true, - allowNull: false, - }, - chatMessageId: { - type: Sequelize.UUID, - allowNull: false, - references: { - model: "ChatMessages", - key: "id", + if (!(await tableExists(queryInterface, "ChatMessageRecipientPayloads"))) { + await queryInterface.createTable("ChatMessageRecipientPayloads", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, }, - onUpdate: "CASCADE", - onDelete: "CASCADE", - }, - recipientUserId: { - type: Sequelize.UUID, - allowNull: false, - references: { - model: "Users", - key: "id", + chatMessageId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "ChatMessages", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", }, - onUpdate: "CASCADE", - onDelete: "CASCADE", - }, - recipientDeviceId: { - type: Sequelize.STRING(128), - allowNull: false, - references: { - model: "UserDevices", - key: "deviceId", + recipientUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "Users", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", }, - onUpdate: "CASCADE", - onDelete: "CASCADE", - }, - senderDeviceId: { - type: Sequelize.STRING(128), - allowNull: false, - }, - encryptedEnvelope: { - type: Sequelize.JSONB, - allowNull: false, - }, - deliveredAt: { - type: Sequelize.DATE, - allowNull: true, - }, - readAt: { - type: Sequelize.DATE, - allowNull: true, - }, - createdAt: { - type: Sequelize.DATE, - allowNull: false, - defaultValue: Sequelize.fn("NOW"), - }, - updatedAt: { - type: Sequelize.DATE, - allowNull: false, - defaultValue: Sequelize.fn("NOW"), - }, - }); + recipientDeviceId: { + type: Sequelize.STRING(128), + allowNull: false, + references: { + model: "UserDevices", + key: "deviceId", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderDeviceId: { + type: Sequelize.STRING(128), + allowNull: false, + }, + encryptedEnvelope: { + type: Sequelize.JSONB, + allowNull: false, + }, + deliveredAt: { + type: Sequelize.DATE, + allowNull: true, + }, + readAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn("NOW"), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn("NOW"), + }, + }); + } - await queryInterface.addIndex("ChatMessageRecipientPayloads", ["chatMessageId"], { + await addIndexIfMissing(queryInterface, "ChatMessageRecipientPayloads", ["chatMessageId"], { name: "chat_message_recipient_payloads_message_idx", }); - await queryInterface.addIndex( + await addIndexIfMissing( + queryInterface, "ChatMessageRecipientPayloads", ["recipientUserId", "recipientDeviceId"], { name: "chat_message_recipient_payloads_recipient_idx", }, ); - await queryInterface.addIndex( + await addIndexIfMissing( + queryInterface, "ChatMessageRecipientPayloads", ["chatMessageId", "recipientDeviceId"], { @@ -88,18 +120,24 @@ module.exports = { }, async down(queryInterface) { - await queryInterface.removeIndex( + await removeIndexIfExists( + queryInterface, "ChatMessageRecipientPayloads", "chat_message_recipient_payloads_message_device_unique", ); - await queryInterface.removeIndex( + await removeIndexIfExists( + queryInterface, "ChatMessageRecipientPayloads", "chat_message_recipient_payloads_recipient_idx", ); - await queryInterface.removeIndex( + await removeIndexIfExists( + queryInterface, "ChatMessageRecipientPayloads", "chat_message_recipient_payloads_message_idx", ); - await queryInterface.dropTable("ChatMessageRecipientPayloads"); + if (await tableExists(queryInterface, "ChatMessageRecipientPayloads")) { + await queryInterface.dropTable("ChatMessageRecipientPayloads"); + } }, }; +