2018-03-27 16:19:40 +00:00
|
|
|
|
// Module to upgrade the schema of messages, e.g. migrate attachments to disk.
|
2018-04-02 22:59:24 +00:00
|
|
|
|
// `dangerouslyProcessAllWithoutIndex` purposely doesn’t rely on our Backbone
|
|
|
|
|
// IndexedDB adapter to prevent automatic migrations. Rather, it uses direct
|
|
|
|
|
// IndexedDB access. This includes avoiding usage of `storage` module which uses
|
|
|
|
|
// Backbone under the hood.
|
2018-03-27 16:19:40 +00:00
|
|
|
|
|
2018-03-28 14:54:01 +00:00
|
|
|
|
/* global IDBKeyRange */
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const { isFunction, isNumber, isObject, isString, last } = require('lodash');
|
2018-03-26 20:33:23 +00:00
|
|
|
|
|
2018-03-28 14:54:01 +00:00
|
|
|
|
const database = require('./database');
|
2018-03-21 23:37:39 +00:00
|
|
|
|
const Message = require('./types/message');
|
2018-03-28 14:54:01 +00:00
|
|
|
|
const settings = require('./settings');
|
2018-03-27 15:51:21 +00:00
|
|
|
|
|
|
|
|
|
const MESSAGES_STORE_NAME = 'messages';
|
2018-03-21 23:37:39 +00:00
|
|
|
|
|
2018-03-26 20:32:22 +00:00
|
|
|
|
exports.processNext = async ({
|
2018-03-21 23:37:39 +00:00
|
|
|
|
BackboneMessage,
|
|
|
|
|
BackboneMessageCollection,
|
2018-04-03 18:43:17 +00:00
|
|
|
|
numMessagesPerBatch,
|
2018-03-21 23:37:39 +00:00
|
|
|
|
upgradeMessageSchema,
|
2018-07-25 22:02:27 +00:00
|
|
|
|
getMessagesNeedingUpgrade,
|
|
|
|
|
saveMessage,
|
2018-03-21 23:37:39 +00:00
|
|
|
|
} = {}) => {
|
|
|
|
|
if (!isFunction(BackboneMessage)) {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
throw new TypeError(
|
|
|
|
|
"'BackboneMessage' (Whisper.Message) constructor is required"
|
|
|
|
|
);
|
2018-03-21 23:37:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFunction(BackboneMessageCollection)) {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
throw new TypeError(
|
|
|
|
|
"'BackboneMessageCollection' (Whisper.MessageCollection)" +
|
|
|
|
|
' constructor is required'
|
|
|
|
|
);
|
2018-03-21 23:37:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-04-03 18:43:17 +00:00
|
|
|
|
if (!isNumber(numMessagesPerBatch)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'numMessagesPerBatch' is required");
|
2018-03-21 23:37:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFunction(upgradeMessageSchema)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'upgradeMessageSchema' is required");
|
2018-03-21 23:37:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
|
2018-03-27 16:19:55 +00:00
|
|
|
|
const fetchStartTime = Date.now();
|
2018-07-25 22:02:27 +00:00
|
|
|
|
const messagesRequiringSchemaUpgrade = await getMessagesNeedingUpgrade(
|
|
|
|
|
numMessagesPerBatch,
|
2018-04-27 21:25:04 +00:00
|
|
|
|
{
|
2018-07-25 22:02:27 +00:00
|
|
|
|
MessageCollection: BackboneMessageCollection,
|
2018-04-27 21:25:04 +00:00
|
|
|
|
}
|
|
|
|
|
);
|
2018-03-27 16:19:55 +00:00
|
|
|
|
const fetchDuration = Date.now() - fetchStartTime;
|
2018-03-21 23:37:39 +00:00
|
|
|
|
|
2018-03-27 16:19:55 +00:00
|
|
|
|
const upgradeStartTime = Date.now();
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const upgradedMessages = await Promise.all(
|
|
|
|
|
messagesRequiringSchemaUpgrade.map(upgradeMessageSchema)
|
|
|
|
|
);
|
2018-03-27 16:19:55 +00:00
|
|
|
|
const upgradeDuration = Date.now() - upgradeStartTime;
|
2018-03-21 23:37:39 +00:00
|
|
|
|
|
2018-03-27 16:19:55 +00:00
|
|
|
|
const saveStartTime = Date.now();
|
2018-07-25 22:02:27 +00:00
|
|
|
|
await Promise.all(
|
|
|
|
|
upgradedMessages.map(message =>
|
|
|
|
|
saveMessage(message, { Message: BackboneMessage })
|
|
|
|
|
)
|
|
|
|
|
);
|
2018-03-27 16:19:55 +00:00
|
|
|
|
const saveDuration = Date.now() - saveStartTime;
|
2018-03-21 23:37:39 +00:00
|
|
|
|
|
|
|
|
|
const totalDuration = Date.now() - startTime;
|
|
|
|
|
const numProcessed = messagesRequiringSchemaUpgrade.length;
|
2018-04-03 18:43:17 +00:00
|
|
|
|
const done = numProcessed < numMessagesPerBatch;
|
2018-03-21 23:37:39 +00:00
|
|
|
|
return {
|
2018-03-30 21:20:50 +00:00
|
|
|
|
done,
|
2018-03-21 23:37:39 +00:00
|
|
|
|
numProcessed,
|
|
|
|
|
fetchDuration,
|
|
|
|
|
upgradeDuration,
|
|
|
|
|
saveDuration,
|
|
|
|
|
totalDuration,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2018-04-02 22:59:24 +00:00
|
|
|
|
exports.dangerouslyProcessAllWithoutIndex = async ({
|
2018-03-28 14:54:01 +00:00
|
|
|
|
databaseName,
|
2018-03-28 18:45:07 +00:00
|
|
|
|
minDatabaseVersion,
|
2018-04-03 18:43:17 +00:00
|
|
|
|
numMessagesPerBatch,
|
2018-03-28 14:54:01 +00:00
|
|
|
|
upgradeMessageSchema,
|
2018-07-21 19:00:08 +00:00
|
|
|
|
logger,
|
2018-03-28 14:54:01 +00:00
|
|
|
|
} = {}) => {
|
2018-03-28 14:23:36 +00:00
|
|
|
|
if (!isString(databaseName)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'databaseName' must be a string");
|
2018-03-28 14:23:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-03-28 18:45:07 +00:00
|
|
|
|
if (!isNumber(minDatabaseVersion)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'minDatabaseVersion' must be a number");
|
2018-03-28 14:23:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-04-03 18:43:17 +00:00
|
|
|
|
if (!isNumber(numMessagesPerBatch)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'numMessagesPerBatch' must be a number");
|
2018-04-03 18:43:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-03-26 23:09:06 +00:00
|
|
|
|
if (!isFunction(upgradeMessageSchema)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'upgradeMessageSchema' is required");
|
2018-03-26 23:09:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-03-28 18:45:07 +00:00
|
|
|
|
const connection = await database.open(databaseName);
|
|
|
|
|
const databaseVersion = connection.version;
|
|
|
|
|
const isValidDatabaseVersion = databaseVersion >= minDatabaseVersion;
|
2018-07-21 19:00:08 +00:00
|
|
|
|
logger.info('Database status', {
|
2018-03-28 18:45:07 +00:00
|
|
|
|
databaseVersion,
|
|
|
|
|
isValidDatabaseVersion,
|
|
|
|
|
minDatabaseVersion,
|
|
|
|
|
});
|
|
|
|
|
if (!isValidDatabaseVersion) {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
throw new Error(
|
|
|
|
|
`Expected database version (${databaseVersion})` +
|
|
|
|
|
` to be at least ${minDatabaseVersion}`
|
|
|
|
|
);
|
2018-03-28 18:45:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-04-02 22:59:24 +00:00
|
|
|
|
// NOTE: Even if we make this async using `then`, requesting `count` on an
|
|
|
|
|
// IndexedDB store blocks all subsequent transactions, so we might as well
|
|
|
|
|
// explicitly wait for it here:
|
|
|
|
|
const numTotalMessages = await _getNumMessages({ connection });
|
2018-03-29 20:21:52 +00:00
|
|
|
|
|
2018-03-27 15:51:21 +00:00
|
|
|
|
const migrationStartTime = Date.now();
|
2018-04-02 22:59:24 +00:00
|
|
|
|
let numCumulativeMessagesProcessed = 0;
|
|
|
|
|
// eslint-disable-next-line no-constant-condition
|
|
|
|
|
while (true) {
|
2018-03-27 15:51:21 +00:00
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2018-04-03 18:43:17 +00:00
|
|
|
|
const status = await _processBatch({
|
|
|
|
|
connection,
|
|
|
|
|
numMessagesPerBatch,
|
|
|
|
|
upgradeMessageSchema,
|
|
|
|
|
});
|
2018-04-02 22:59:24 +00:00
|
|
|
|
if (status.done) {
|
|
|
|
|
break;
|
2018-03-27 15:51:21 +00:00
|
|
|
|
}
|
2018-04-02 22:59:24 +00:00
|
|
|
|
numCumulativeMessagesProcessed += status.numMessagesProcessed;
|
2018-07-21 19:00:08 +00:00
|
|
|
|
logger.info(
|
2018-04-27 21:25:04 +00:00
|
|
|
|
'Upgrade message schema:',
|
|
|
|
|
Object.assign({}, status, {
|
|
|
|
|
numTotalMessages,
|
|
|
|
|
numCumulativeMessagesProcessed,
|
|
|
|
|
})
|
|
|
|
|
);
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
2018-03-28 14:37:21 +00:00
|
|
|
|
|
2018-07-21 19:00:08 +00:00
|
|
|
|
logger.info('Close database connection');
|
2018-03-27 15:51:21 +00:00
|
|
|
|
connection.close();
|
|
|
|
|
|
|
|
|
|
const totalDuration = Date.now() - migrationStartTime;
|
2018-07-21 19:00:08 +00:00
|
|
|
|
logger.info('Attachment migration complete:', {
|
2018-03-28 14:24:07 +00:00
|
|
|
|
totalDuration,
|
2018-04-02 22:59:24 +00:00
|
|
|
|
totalMessagesProcessed: numCumulativeMessagesProcessed,
|
2018-03-28 14:24:07 +00:00
|
|
|
|
});
|
2018-03-26 23:09:06 +00:00
|
|
|
|
};
|
|
|
|
|
|
2018-04-02 22:59:24 +00:00
|
|
|
|
exports.processNextBatchWithoutIndex = async ({
|
|
|
|
|
databaseName,
|
|
|
|
|
minDatabaseVersion,
|
2018-04-03 18:43:17 +00:00
|
|
|
|
numMessagesPerBatch,
|
2018-04-02 22:59:24 +00:00
|
|
|
|
upgradeMessageSchema,
|
|
|
|
|
} = {}) => {
|
|
|
|
|
if (!isFunction(upgradeMessageSchema)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'upgradeMessageSchema' is required");
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const connection = await _getConnection({ databaseName, minDatabaseVersion });
|
2018-04-03 18:43:17 +00:00
|
|
|
|
const batch = await _processBatch({
|
|
|
|
|
connection,
|
|
|
|
|
numMessagesPerBatch,
|
|
|
|
|
upgradeMessageSchema,
|
|
|
|
|
});
|
2018-04-02 22:59:24 +00:00
|
|
|
|
return batch;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Private API
|
|
|
|
|
const _getConnection = async ({ databaseName, minDatabaseVersion }) => {
|
|
|
|
|
if (!isString(databaseName)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'databaseName' must be a string");
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isNumber(minDatabaseVersion)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'minDatabaseVersion' must be a number");
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const connection = await database.open(databaseName);
|
|
|
|
|
const databaseVersion = connection.version;
|
|
|
|
|
const isValidDatabaseVersion = databaseVersion >= minDatabaseVersion;
|
|
|
|
|
if (!isValidDatabaseVersion) {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
throw new Error(
|
|
|
|
|
`Expected database version (${databaseVersion})` +
|
|
|
|
|
` to be at least ${minDatabaseVersion}`
|
|
|
|
|
);
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return connection;
|
|
|
|
|
};
|
|
|
|
|
|
2018-04-03 18:43:17 +00:00
|
|
|
|
const _processBatch = async ({
|
|
|
|
|
connection,
|
|
|
|
|
numMessagesPerBatch,
|
|
|
|
|
upgradeMessageSchema,
|
|
|
|
|
} = {}) => {
|
2018-04-02 22:59:24 +00:00
|
|
|
|
if (!isObject(connection)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'connection' must be a string");
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFunction(upgradeMessageSchema)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'upgradeMessageSchema' is required");
|
2018-04-02 22:59:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-04-03 18:43:17 +00:00
|
|
|
|
if (!isNumber(numMessagesPerBatch)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'numMessagesPerBatch' is required");
|
2018-04-03 18:43:17 +00:00
|
|
|
|
}
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const isAttachmentMigrationComplete = await settings.isAttachmentMigrationComplete(
|
|
|
|
|
connection
|
|
|
|
|
);
|
2018-04-02 22:59:24 +00:00
|
|
|
|
if (isAttachmentMigrationComplete) {
|
|
|
|
|
return {
|
|
|
|
|
done: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const lastProcessedIndex = await settings.getAttachmentMigrationLastProcessedIndex(
|
|
|
|
|
connection
|
|
|
|
|
);
|
2018-04-02 22:59:24 +00:00
|
|
|
|
|
|
|
|
|
const fetchUnprocessedMessagesStartTime = Date.now();
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const unprocessedMessages = await _dangerouslyFetchMessagesRequiringSchemaUpgradeWithoutIndex(
|
|
|
|
|
{
|
2018-04-02 22:59:24 +00:00
|
|
|
|
connection,
|
2018-04-03 18:43:17 +00:00
|
|
|
|
count: numMessagesPerBatch,
|
2018-04-02 22:59:24 +00:00
|
|
|
|
lastIndex: lastProcessedIndex,
|
2018-04-27 21:25:04 +00:00
|
|
|
|
}
|
|
|
|
|
);
|
2018-04-02 22:59:24 +00:00
|
|
|
|
const fetchDuration = Date.now() - fetchUnprocessedMessagesStartTime;
|
|
|
|
|
|
|
|
|
|
const upgradeStartTime = Date.now();
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const upgradedMessages = await Promise.all(
|
|
|
|
|
unprocessedMessages.map(upgradeMessageSchema)
|
|
|
|
|
);
|
2018-04-02 22:59:24 +00:00
|
|
|
|
const upgradeDuration = Date.now() - upgradeStartTime;
|
|
|
|
|
|
|
|
|
|
const saveMessagesStartTime = Date.now();
|
|
|
|
|
const transaction = connection.transaction(MESSAGES_STORE_NAME, 'readwrite');
|
|
|
|
|
const transactionCompletion = database.completeTransaction(transaction);
|
|
|
|
|
await Promise.all(upgradedMessages.map(_saveMessage({ transaction })));
|
|
|
|
|
await transactionCompletion;
|
|
|
|
|
const saveDuration = Date.now() - saveMessagesStartTime;
|
|
|
|
|
|
|
|
|
|
const numMessagesProcessed = upgradedMessages.length;
|
2018-04-03 18:43:17 +00:00
|
|
|
|
const done = numMessagesProcessed < numMessagesPerBatch;
|
2018-04-02 22:59:24 +00:00
|
|
|
|
const lastMessage = last(upgradedMessages);
|
|
|
|
|
const newLastProcessedIndex = lastMessage ? lastMessage.id : null;
|
|
|
|
|
if (!done) {
|
|
|
|
|
await settings.setAttachmentMigrationLastProcessedIndex(
|
|
|
|
|
connection,
|
|
|
|
|
newLastProcessedIndex
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await settings.markAttachmentMigrationComplete(connection);
|
|
|
|
|
await settings.deleteAttachmentMigrationLastProcessedIndex(connection);
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-02 23:01:30 +00:00
|
|
|
|
const batchTotalDuration = Date.now() - fetchUnprocessedMessagesStartTime;
|
|
|
|
|
|
2018-04-02 22:59:24 +00:00
|
|
|
|
return {
|
2018-04-02 23:01:30 +00:00
|
|
|
|
batchTotalDuration,
|
2018-04-02 22:59:24 +00:00
|
|
|
|
done,
|
|
|
|
|
fetchDuration,
|
|
|
|
|
lastProcessedIndex,
|
|
|
|
|
newLastProcessedIndex,
|
|
|
|
|
numMessagesProcessed,
|
|
|
|
|
saveDuration,
|
|
|
|
|
targetSchemaVersion: Message.CURRENT_SCHEMA_VERSION,
|
|
|
|
|
upgradeDuration,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const _saveMessage = ({ transaction } = {}) => message => {
|
2018-03-27 15:51:21 +00:00
|
|
|
|
if (!isObject(transaction)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'transaction' is required");
|
2018-03-27 15:51:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messagesStore = transaction.objectStore(MESSAGES_STORE_NAME);
|
|
|
|
|
const request = messagesStore.put(message, message.id);
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
request.onsuccess = () => resolve();
|
|
|
|
|
request.onerror = event => reject(event.target.error);
|
2018-03-27 15:51:21 +00:00
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2018-03-29 17:12:18 +00:00
|
|
|
|
// NOTE: Named ‘dangerous’ because it is not as efficient as using our
|
|
|
|
|
// `messages` `schemaVersion` index:
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const _dangerouslyFetchMessagesRequiringSchemaUpgradeWithoutIndex = ({
|
|
|
|
|
connection,
|
|
|
|
|
count,
|
|
|
|
|
lastIndex,
|
|
|
|
|
} = {}) => {
|
|
|
|
|
if (!isObject(connection)) {
|
|
|
|
|
throw new TypeError("'connection' is required");
|
|
|
|
|
}
|
2018-03-26 23:09:06 +00:00
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
if (!isNumber(count)) {
|
|
|
|
|
throw new TypeError("'count' is required");
|
|
|
|
|
}
|
2018-03-26 23:09:06 +00:00
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
if (lastIndex && !isString(lastIndex)) {
|
|
|
|
|
throw new TypeError("'lastIndex' must be a string");
|
|
|
|
|
}
|
2018-03-26 23:09:06 +00:00
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const hasLastIndex = Boolean(lastIndex);
|
|
|
|
|
|
|
|
|
|
const transaction = connection.transaction(MESSAGES_STORE_NAME, 'readonly');
|
|
|
|
|
const messagesStore = transaction.objectStore(MESSAGES_STORE_NAME);
|
|
|
|
|
|
|
|
|
|
const excludeLowerBound = true;
|
|
|
|
|
const range = hasLastIndex
|
|
|
|
|
? IDBKeyRange.lowerBound(lastIndex, excludeLowerBound)
|
|
|
|
|
: undefined;
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const items = [];
|
|
|
|
|
const request = messagesStore.openCursor(range);
|
|
|
|
|
request.onsuccess = event => {
|
|
|
|
|
const cursor = event.target.result;
|
|
|
|
|
const hasMoreData = Boolean(cursor);
|
|
|
|
|
if (!hasMoreData || items.length === count) {
|
|
|
|
|
resolve(items);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const item = cursor.value;
|
|
|
|
|
items.push(item);
|
|
|
|
|
cursor.continue();
|
|
|
|
|
};
|
|
|
|
|
request.onerror = event => reject(event.target.error);
|
|
|
|
|
});
|
|
|
|
|
};
|
2018-03-29 20:21:52 +00:00
|
|
|
|
|
2018-04-02 22:59:24 +00:00
|
|
|
|
const _getNumMessages = async ({ connection } = {}) => {
|
2018-03-29 20:21:52 +00:00
|
|
|
|
if (!isObject(connection)) {
|
2018-04-11 19:44:52 +00:00
|
|
|
|
throw new TypeError("'connection' is required");
|
2018-03-29 20:21:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transaction = connection.transaction(MESSAGES_STORE_NAME, 'readonly');
|
|
|
|
|
const messagesStore = transaction.objectStore(MESSAGES_STORE_NAME);
|
|
|
|
|
const numTotalMessages = await database.getCount({ store: messagesStore });
|
|
|
|
|
await database.completeTransaction(transaction);
|
|
|
|
|
|
|
|
|
|
return numTotalMessages;
|
|
|
|
|
};
|