signal-desktop/app/sql.js

3321 lines
78 KiB
JavaScript

const { join } = require('path');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
const sql = require('@journeyapps/sqlcipher');
const { app, dialog, clipboard } = require('electron');
const { redactAll } = require('../js/modules/privacy');
const { remove: removeUserConfig } = require('./user_config');
const pify = require('pify');
const uuidv4 = require('uuid/v4');
const {
forEach,
fromPairs,
isNumber,
isObject,
isString,
last,
map,
pick,
} = require('lodash');
module.exports = {
initialize,
close,
removeDB,
removeIndexedDBFiles,
createOrUpdateIdentityKey,
getIdentityKeyById,
bulkAddIdentityKeys,
removeIdentityKeyById,
removeAllIdentityKeys,
getAllIdentityKeys,
createOrUpdatePreKey,
getPreKeyById,
bulkAddPreKeys,
removePreKeyById,
removeAllPreKeys,
getAllPreKeys,
createOrUpdateSignedPreKey,
getSignedPreKeyById,
getAllSignedPreKeys,
bulkAddSignedPreKeys,
removeSignedPreKeyById,
removeAllSignedPreKeys,
createOrUpdateItem,
getItemById,
getAllItems,
bulkAddItems,
removeItemById,
removeAllItems,
createOrUpdateSession,
createOrUpdateSessions,
getSessionById,
getSessionsByNumber,
bulkAddSessions,
removeSessionById,
removeSessionsByNumber,
removeAllSessions,
getAllSessions,
getConversationCount,
saveConversation,
saveConversations,
getConversationById,
updateConversation,
updateConversations,
removeConversation,
getAllConversations,
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
searchConversations,
searchMessages,
searchMessagesInConversation,
getMessageCount,
saveMessage,
saveMessages,
removeMessage,
getUnreadByConversation,
getMessageBySender,
getMessageById,
getAllMessages,
getAllMessageIds,
getMessagesBySentAt,
getExpiredMessages,
getOutgoingWithoutExpiresAt,
getNextExpiringMessage,
getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase,
getOlderMessagesByConversation,
getNewerMessagesByConversation,
getMessageMetricsForConversation,
getUnprocessedCount,
getAllUnprocessed,
saveUnprocessed,
updateUnprocessedAttempts,
updateUnprocessedWithData,
updateUnprocessedsWithData,
getUnprocessedById,
saveUnprocesseds,
removeUnprocessed,
removeAllUnprocessed,
getNextAttachmentDownloadJobs,
saveAttachmentDownloadJob,
setAttachmentDownloadJobPending,
resetAttachmentDownloadPending,
removeAttachmentDownloadJob,
removeAllAttachmentDownloadJobs,
createOrUpdateStickerPack,
updateStickerPackStatus,
createOrUpdateSticker,
updateStickerLastUsed,
addStickerPackReference,
deleteStickerPackReference,
deleteStickerPack,
getAllStickerPacks,
getAllStickers,
getRecentStickers,
updateEmojiUsage,
getRecentEmojis,
removeAll,
removeAllConfiguration,
getMessagesNeedingUpgrade,
getMessagesWithVisualMediaAttachments,
getMessagesWithFileAttachments,
removeKnownAttachments,
removeKnownStickers,
removeKnownDraftAttachments,
};
function generateUUID() {
return uuidv4();
}
function objectToJSON(data) {
return JSON.stringify(data);
}
function jsonToObject(json) {
return JSON.parse(json);
}
async function openDatabase(filePath) {
return new Promise((resolve, reject) => {
const instance = new sql.Database(filePath, error => {
if (error) {
return reject(error);
}
return resolve(instance);
});
});
}
function promisify(rawInstance) {
/* eslint-disable no-param-reassign */
rawInstance.close = pify(rawInstance.close.bind(rawInstance));
rawInstance.run = pify(rawInstance.run.bind(rawInstance));
rawInstance.get = pify(rawInstance.get.bind(rawInstance));
rawInstance.all = pify(rawInstance.all.bind(rawInstance));
rawInstance.each = pify(rawInstance.each.bind(rawInstance));
rawInstance.exec = pify(rawInstance.exec.bind(rawInstance));
rawInstance.prepare = pify(rawInstance.prepare.bind(rawInstance));
/* eslint-enable */
return rawInstance;
}
async function getSQLiteVersion(instance) {
const row = await instance.get('select sqlite_version() AS sqlite_version');
return row.sqlite_version;
}
async function getSchemaVersion(instance) {
const row = await instance.get('PRAGMA schema_version;');
return row.schema_version;
}
async function setUserVersion(instance, version) {
if (!isNumber(version)) {
throw new Error(`setUserVersion: version ${version} is not a number`);
}
await instance.get(`PRAGMA user_version = ${version};`);
}
async function keyDatabase(instance, key) {
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
await instance.run(`PRAGMA key = "x'${key}'";`);
}
async function getUserVersion(instance) {
const row = await instance.get('PRAGMA user_version;');
return row.user_version;
}
async function getSQLCipherVersion(instance) {
const row = await instance.get('PRAGMA cipher_version;');
try {
return row.cipher_version;
} catch (e) {
return null;
}
}
async function getSQLCipherIntegrityCheck(instance) {
const row = await instance.get('PRAGMA cipher_integrity_check;');
if (row) {
return row.cipher_integrity_check;
}
return null;
}
async function getSQLIntegrityCheck(instance) {
const row = await instance.get('PRAGMA integrity_check;');
if (row && row.integrity_check !== 'ok') {
return row.integrity_check;
}
return null;
}
async function migrateSchemaVersion(instance) {
const userVersion = await getUserVersion(instance);
if (userVersion > 0) {
return;
}
const schemaVersion = await getSchemaVersion(instance);
const newUserVersion = schemaVersion > 18 ? 16 : schemaVersion;
console.log(
`migrateSchemaVersion: Migrating from schema_version ${schemaVersion} to user_version ${newUserVersion}`
);
await setUserVersion(instance, newUserVersion);
}
async function openAndMigrateDatabase(filePath, key) {
let promisified;
// First, we try to open the database without any cipher changes
try {
const instance = await openDatabase(filePath);
promisified = promisify(instance);
keyDatabase(promisified, key);
await migrateSchemaVersion(promisified);
return promisified;
} catch (error) {
if (promisified) {
await promisified.close();
}
console.log('migrateDatabase: Migration without cipher change failed');
}
// If that fails, we try to open the database with 3.x compatibility to extract the
// user_version (previously stored in schema_version, blown away by cipher_migrate).
const instance = await openDatabase(filePath);
promisified = promisify(instance);
keyDatabase(promisified, key);
// https://www.zetetic.net/blog/2018/11/30/sqlcipher-400-release/#compatability-sqlcipher-4-0-0
await promisified.run('PRAGMA cipher_compatibility = 3;');
await migrateSchemaVersion(promisified);
await promisified.close();
// After migrating user_version -> schema_version, we reopen database, because we can't
// migrate to the latest ciphers after we've modified the defaults.
const instance2 = await openDatabase(filePath);
promisified = promisify(instance2);
keyDatabase(promisified, key);
await promisified.run('PRAGMA cipher_migrate;');
return promisified;
}
const INVALID_KEY = /[^0-9A-Fa-f]/;
async function openAndSetUpSQLCipher(filePath, { key }) {
const match = INVALID_KEY.exec(key);
if (match) {
throw new Error(`setupSQLCipher: key '${key}' is not valid`);
}
const instance = await openAndMigrateDatabase(filePath, key);
// Because foreign key support is not enabled by default!
await instance.run('PRAGMA foreign_keys = ON;');
return instance;
}
async function updateToSchemaVersion1(currentVersion, instance) {
if (currentVersion >= 1) {
return;
}
console.log('updateToSchemaVersion1: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(
`CREATE TABLE messages(
id STRING PRIMARY KEY ASC,
json TEXT,
unread INTEGER,
expires_at INTEGER,
sent_at INTEGER,
schemaVersion INTEGER,
conversationId STRING,
received_at INTEGER,
source STRING,
sourceDevice STRING,
hasAttachments INTEGER,
hasFileAttachments INTEGER,
hasVisualMediaAttachments INTEGER
);`
);
await instance.run(`CREATE INDEX messages_unread ON messages (
unread
);`);
await instance.run(`CREATE INDEX messages_expires_at ON messages (
expires_at
);`);
await instance.run(`CREATE INDEX messages_receipt ON messages (
sent_at
);`);
await instance.run(`CREATE INDEX messages_schemaVersion ON messages (
schemaVersion
);`);
await instance.run(`CREATE INDEX messages_conversation ON messages (
conversationId,
received_at
);`);
await instance.run(`CREATE INDEX messages_duplicate_check ON messages (
source,
sourceDevice,
sent_at
);`);
await instance.run(`CREATE INDEX messages_hasAttachments ON messages (
conversationId,
hasAttachments,
received_at
);`);
await instance.run(`CREATE INDEX messages_hasFileAttachments ON messages (
conversationId,
hasFileAttachments,
received_at
);`);
await instance.run(`CREATE INDEX messages_hasVisualMediaAttachments ON messages (
conversationId,
hasVisualMediaAttachments,
received_at
);`);
await instance.run(`CREATE TABLE unprocessed(
id STRING,
timestamp INTEGER,
json TEXT
);`);
await instance.run(`CREATE INDEX unprocessed_id ON unprocessed (
id
);`);
await instance.run(`CREATE INDEX unprocessed_timestamp ON unprocessed (
timestamp
);`);
await instance.run('PRAGMA user_version = 1;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion1: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion2(currentVersion, instance) {
if (currentVersion >= 2) {
return;
}
console.log('updateToSchemaVersion2: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(
`ALTER TABLE messages
ADD COLUMN expireTimer INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN expirationStartTimestamp INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN type STRING;`
);
await instance.run(`CREATE INDEX messages_expiring ON messages (
expireTimer,
expirationStartTimestamp,
expires_at
);`);
await instance.run(
`UPDATE messages SET
expirationStartTimestamp = json_extract(json, '$.expirationStartTimestamp'),
expireTimer = json_extract(json, '$.expireTimer'),
type = json_extract(json, '$.type');`
);
await instance.run('PRAGMA user_version = 2;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion2: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion3(currentVersion, instance) {
if (currentVersion >= 3) {
return;
}
console.log('updateToSchemaVersion3: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run('DROP INDEX messages_expiring;');
await instance.run('DROP INDEX messages_unread;');
await instance.run(`CREATE INDEX messages_without_timer ON messages (
expireTimer,
expires_at,
type
) WHERE expires_at IS NULL AND expireTimer IS NOT NULL;`);
await instance.run(`CREATE INDEX messages_unread ON messages (
conversationId,
unread
) WHERE unread IS NOT NULL;`);
await instance.run('ANALYZE;');
await instance.run('PRAGMA user_version = 3;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion3: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion4(currentVersion, instance) {
if (currentVersion >= 4) {
return;
}
console.log('updateToSchemaVersion4: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(
`CREATE TABLE conversations(
id STRING PRIMARY KEY ASC,
json TEXT,
active_at INTEGER,
type STRING,
members TEXT,
name TEXT,
profileName TEXT
);`
);
await instance.run(`CREATE INDEX conversations_active ON conversations (
active_at
) WHERE active_at IS NOT NULL;`);
await instance.run(`CREATE INDEX conversations_type ON conversations (
type
) WHERE type IS NOT NULL;`);
await instance.run('PRAGMA user_version = 4;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion4: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion6(currentVersion, instance) {
if (currentVersion >= 6) {
return;
}
console.log('updateToSchemaVersion6: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
// key-value, ids are strings, one extra column
await instance.run(
`CREATE TABLE sessions(
id STRING PRIMARY KEY ASC,
number STRING,
json TEXT
);`
);
await instance.run(`CREATE INDEX sessions_number ON sessions (
number
) WHERE number IS NOT NULL;`);
// key-value, ids are strings
await instance.run(
`CREATE TABLE groups(
id STRING PRIMARY KEY ASC,
json TEXT
);`
);
await instance.run(
`CREATE TABLE identityKeys(
id STRING PRIMARY KEY ASC,
json TEXT
);`
);
await instance.run(
`CREATE TABLE items(
id STRING PRIMARY KEY ASC,
json TEXT
);`
);
// key-value, ids are integers
await instance.run(
`CREATE TABLE preKeys(
id INTEGER PRIMARY KEY ASC,
json TEXT
);`
);
await instance.run(
`CREATE TABLE signedPreKeys(
id INTEGER PRIMARY KEY ASC,
json TEXT
);`
);
await instance.run('PRAGMA user_version = 6;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion6: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion7(currentVersion, instance) {
if (currentVersion >= 7) {
return;
}
console.log('updateToSchemaVersion7: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
// SQLite has been coercing our STRINGs into numbers, so we force it with TEXT
// We create a new table then copy the data into it, since we can't modify columns
await instance.run('DROP INDEX sessions_number;');
await instance.run('ALTER TABLE sessions RENAME TO sessions_old;');
await instance.run(
`CREATE TABLE sessions(
id TEXT PRIMARY KEY,
number TEXT,
json TEXT
);`
);
await instance.run(`CREATE INDEX sessions_number ON sessions (
number
) WHERE number IS NOT NULL;`);
await instance.run(`INSERT INTO sessions(id, number, json)
SELECT "+" || id, number, json FROM sessions_old;
`);
await instance.run('DROP TABLE sessions_old;');
await instance.run('PRAGMA user_version = 7;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion7: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion8(currentVersion, instance) {
if (currentVersion >= 8) {
return;
}
console.log('updateToSchemaVersion8: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
// First, we pull a new body field out of the message table's json blob
await instance.run(
`ALTER TABLE messages
ADD COLUMN body TEXT;`
);
await instance.run(
"UPDATE messages SET body = json_extract(json, '$.body')"
);
// Then we create our full-text search table and populate it
await instance.run(`
CREATE VIRTUAL TABLE messages_fts
USING fts5(id UNINDEXED, body);
`);
await instance.run(`
INSERT INTO messages_fts(id, body)
SELECT id, body FROM messages;
`);
// Then we set up triggers to keep the full-text search table up to date
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE id = old.id;
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
// For formatting search results:
// https://sqlite.org/fts5.html#the_highlight_function
// https://sqlite.org/fts5.html#the_snippet_function
await instance.run('PRAGMA user_version = 8;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion8: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion9(currentVersion, instance) {
if (currentVersion >= 9) {
return;
}
console.log('updateToSchemaVersion9: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(`CREATE TABLE attachment_downloads(
id STRING primary key,
timestamp INTEGER,
pending INTEGER,
json TEXT
);`);
await instance.run(`CREATE INDEX attachment_downloads_timestamp
ON attachment_downloads (
timestamp
) WHERE pending = 0;`);
await instance.run(`CREATE INDEX attachment_downloads_pending
ON attachment_downloads (
pending
) WHERE pending != 0;`);
await instance.run('PRAGMA user_version = 9;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion9: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion10(currentVersion, instance) {
if (currentVersion >= 10) {
return;
}
console.log('updateToSchemaVersion10: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run('DROP INDEX unprocessed_id;');
await instance.run('DROP INDEX unprocessed_timestamp;');
await instance.run('ALTER TABLE unprocessed RENAME TO unprocessed_old;');
await instance.run(`CREATE TABLE unprocessed(
id STRING,
timestamp INTEGER,
version INTEGER,
attempts INTEGER,
envelope TEXT,
decrypted TEXT,
source TEXT,
sourceDevice TEXT,
serverTimestamp INTEGER
);`);
await instance.run(`CREATE INDEX unprocessed_id ON unprocessed (
id
);`);
await instance.run(`CREATE INDEX unprocessed_timestamp ON unprocessed (
timestamp
);`);
await instance.run(`INSERT INTO unprocessed (
id,
timestamp,
version,
attempts,
envelope,
decrypted,
source,
sourceDevice,
serverTimestamp
) SELECT
id,
timestamp,
json_extract(json, '$.version'),
json_extract(json, '$.attempts'),
json_extract(json, '$.envelope'),
json_extract(json, '$.decrypted'),
json_extract(json, '$.source'),
json_extract(json, '$.sourceDevice'),
json_extract(json, '$.serverTimestamp')
FROM unprocessed_old;
`);
await instance.run('DROP TABLE unprocessed_old;');
await instance.run('PRAGMA user_version = 10;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion10: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion11(currentVersion, instance) {
if (currentVersion >= 11) {
return;
}
console.log('updateToSchemaVersion11: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run('DROP TABLE groups;');
await instance.run('PRAGMA user_version = 11;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion11: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion12(currentVersion, instance) {
if (currentVersion >= 12) {
return;
}
console.log('updateToSchemaVersion12: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(`CREATE TABLE sticker_packs(
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
author STRING,
coverStickerId INTEGER,
createdAt INTEGER,
downloadAttempts INTEGER,
installedAt INTEGER,
lastUsed INTEGER,
status STRING,
stickerCount INTEGER,
title STRING
);`);
await instance.run(`CREATE TABLE stickers(
id INTEGER NOT NULL,
packId TEXT NOT NULL,
emoji STRING,
height INTEGER,
isCoverOnly INTEGER,
lastUsed INTEGER,
path STRING,
width INTEGER,
PRIMARY KEY (id, packId),
CONSTRAINT stickers_fk
FOREIGN KEY (packId)
REFERENCES sticker_packs(id)
ON DELETE CASCADE
);`);
await instance.run(`CREATE INDEX stickers_recents
ON stickers (
lastUsed
) WHERE lastUsed IS NOT NULL;`);
await instance.run(`CREATE TABLE sticker_references(
messageId STRING,
packId TEXT,
CONSTRAINT sticker_references_fk
FOREIGN KEY(packId)
REFERENCES sticker_packs(id)
ON DELETE CASCADE
);`);
await instance.run('PRAGMA user_version = 12;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion12: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion13(currentVersion, instance) {
if (currentVersion >= 13) {
return;
}
console.log('updateToSchemaVersion13: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(
'ALTER TABLE sticker_packs ADD COLUMN attemptedStatus STRING;'
);
await instance.run('PRAGMA user_version = 13;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion13: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion14(currentVersion, instance) {
if (currentVersion >= 14) {
return;
}
console.log('updateToSchemaVersion14: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(`CREATE TABLE emojis(
shortName STRING PRIMARY KEY,
lastUsage INTEGER
);`);
await instance.run(`CREATE INDEX emojis_lastUsage
ON emojis (
lastUsage
);`);
await instance.run('PRAGMA user_version = 14;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion14: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion15(currentVersion, instance) {
if (currentVersion >= 15) {
return;
}
console.log('updateToSchemaVersion15: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
// SQLite has again coerced our STRINGs into numbers, so we force it with TEXT
// We create a new table then copy the data into it, since we can't modify columns
await instance.run('DROP INDEX emojis_lastUsage;');
await instance.run('ALTER TABLE emojis RENAME TO emojis_old;');
await instance.run(`CREATE TABLE emojis(
shortName TEXT PRIMARY KEY,
lastUsage INTEGER
);`);
await instance.run(`CREATE INDEX emojis_lastUsage
ON emojis (
lastUsage
);`);
await instance.run('DELETE FROM emojis WHERE shortName = 1');
await instance.run(`INSERT INTO emojis(shortName, lastUsage)
SELECT shortName, lastUsage FROM emojis_old;
`);
await instance.run('DROP TABLE emojis_old;');
await instance.run('PRAGMA user_version = 15;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion15: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion16(currentVersion, instance) {
if (currentVersion >= 16) {
return;
}
console.log('updateToSchemaVersion16: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(
`ALTER TABLE messages
ADD COLUMN messageTimer INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN messageTimerStart INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN messageTimerExpiresAt INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN isErased INTEGER;`
);
await instance.run(`CREATE INDEX messages_message_timer ON messages (
messageTimer,
messageTimerStart,
messageTimerExpiresAt,
isErased
) WHERE messageTimer IS NOT NULL;`);
// Updating full-text triggers to avoid anything with a messageTimer set
await instance.run('DROP TRIGGER messages_on_insert;');
await instance.run('DROP TRIGGER messages_on_delete;');
await instance.run('DROP TRIGGER messages_on_update;');
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
WHEN new.messageTimer IS NULL
BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE id = old.id;
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
WHEN new.messageTimer IS NULL
BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run('PRAGMA user_version = 16;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion16: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion17(currentVersion, instance) {
if (currentVersion >= 17) {
return;
}
console.log('updateToSchemaVersion17: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
try {
await instance.run(
`ALTER TABLE messages
ADD COLUMN isViewOnce INTEGER;`
);
await instance.run('DROP INDEX messages_message_timer;');
} catch (error) {
console.log(
'updateToSchemaVersion17: Message table already had isViewOnce column'
);
}
try {
await instance.run('DROP INDEX messages_view_once;');
} catch (error) {
console.log(
'updateToSchemaVersion17: Index messages_view_once did not already exist'
);
}
await instance.run(`CREATE INDEX messages_view_once ON messages (
isErased
) WHERE isViewOnce = 1;`);
// Updating full-text triggers to avoid anything with isViewOnce = 1
await instance.run('DROP TRIGGER messages_on_insert;');
await instance.run('DROP TRIGGER messages_on_update;');
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
WHEN new.isViewOnce != 1
BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
WHEN new.isViewOnce != 1
BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run('PRAGMA user_version = 17;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion17: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
async function updateToSchemaVersion18(currentVersion, instance) {
if (currentVersion >= 18) {
return;
}
console.log('updateToSchemaVersion18: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
// Delete and rebuild full-text search index to capture everything
await instance.run('DELETE FROM messages_fts;');
await instance.run(
"INSERT INTO messages_fts(messages_fts) VALUES('rebuild');"
);
await instance.run(`
INSERT INTO messages_fts(id, body)
SELECT id, body FROM messages WHERE isViewOnce IS NULL OR isViewOnce != 1;
`);
// Fixing full-text triggers
await instance.run('DROP TRIGGER messages_on_insert;');
await instance.run('DROP TRIGGER messages_on_update;');
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1
BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1
BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run('PRAGMA user_version = 18;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion18: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
updateToSchemaVersion3,
updateToSchemaVersion4,
() => null, // version 5 was dropped
updateToSchemaVersion6,
updateToSchemaVersion7,
updateToSchemaVersion8,
updateToSchemaVersion9,
updateToSchemaVersion10,
updateToSchemaVersion11,
updateToSchemaVersion12,
updateToSchemaVersion13,
updateToSchemaVersion14,
updateToSchemaVersion15,
updateToSchemaVersion16,
updateToSchemaVersion17,
updateToSchemaVersion18,
];
async function updateSchema(instance) {
const sqliteVersion = await getSQLiteVersion(instance);
const sqlcipherVersion = await getSQLCipherVersion(instance);
const userVersion = await getUserVersion(instance);
const schemaVersion = await getSchemaVersion(instance);
console.log(
'updateSchema:\n',
` Current user_version: ${userVersion};\n`,
` Most recent db schema: ${SCHEMA_VERSIONS.length};\n`,
` SQLite version: ${sqliteVersion};\n`,
` SQLCipher version: ${sqlcipherVersion};\n`,
` (deprecated) schema_version: ${schemaVersion};\n`
);
for (let index = 0, max = SCHEMA_VERSIONS.length; index < max; index += 1) {
const runSchemaUpdate = SCHEMA_VERSIONS[index];
// Yes, we really want to do this asynchronously, in order
// eslint-disable-next-line no-await-in-loop
await runSchemaUpdate(userVersion, instance);
}
}
let db;
let filePath;
let indexedDBPath;
async function initialize({ configDir, key, messages }) {
if (db) {
throw new Error('Cannot initialize more than once!');
}
if (!isString(configDir)) {
throw new Error('initialize: configDir is required!');
}
if (!isString(key)) {
throw new Error('initialize: key is required!');
}
if (!isObject(messages)) {
throw new Error('initialize: message is required!');
}
indexedDBPath = join(configDir, 'IndexedDB');
const dbDir = join(configDir, 'sql');
mkdirp.sync(dbDir);
filePath = join(dbDir, 'db.sqlite');
let promisified;
try {
promisified = await openAndSetUpSQLCipher(filePath, { key });
// promisified.on('trace', async statement => {
// if (
// !db ||
// statement.startsWith('--') ||
// statement.includes('COMMIT') ||
// statement.includes('BEGIN') ||
// statement.includes('ROLLBACK')
// ) {
// return;
// }
// // Note that this causes problems when attempting to commit transactions - this
// // statement is running, and we get at SQLITE_BUSY error. So we delay.
// await new Promise(resolve => setTimeout(resolve, 1000));
// const data = await db.get(`EXPLAIN QUERY PLAN ${statement}`);
// console._log(`EXPLAIN QUERY PLAN ${statement}\n`, data && data.detail);
// });
await updateSchema(promisified);
// test database
const cipherIntegrityResult = await getSQLCipherIntegrityCheck(promisified);
if (cipherIntegrityResult) {
console.log(
'Database cipher integrity check failed:',
cipherIntegrityResult
);
throw new Error(
`Cipher integrity check failed: ${cipherIntegrityResult}`
);
}
const integrityResult = await getSQLIntegrityCheck(promisified);
if (integrityResult) {
console.log('Database integrity check failed:', integrityResult);
throw new Error(`Integrity check failed: ${integrityResult}`);
}
// At this point we can allow general access to the database
db = promisified;
// test database
await getMessageCount();
} catch (error) {
console.log('Database startup error:', error.stack);
const buttonIndex = dialog.showMessageBoxSync({
buttons: [
messages.copyErrorAndQuit.message,
messages.deleteAndRestart.message,
],
defaultId: 0,
detail: redactAll(error.stack),
message: messages.databaseError.message,
noLink: true,
type: 'error',
});
if (buttonIndex === 0) {
clipboard.writeText(
`Database startup error:\n\n${redactAll(error.stack)}`
);
} else {
if (promisified) {
await promisified.close();
}
await removeDB();
removeUserConfig();
app.relaunch();
}
app.exit(1);
return false;
}
return true;
}
async function close() {
if (!db) {
return;
}
const dbRef = db;
db = null;
await dbRef.close();
}
async function removeDB() {
if (db) {
throw new Error('removeDB: Cannot erase database when it is open!');
}
rimraf.sync(filePath);
}
async function removeIndexedDBFiles() {
if (!indexedDBPath) {
throw new Error(
'removeIndexedDBFiles: Need to initialize and set indexedDBPath first!'
);
}
const pattern = join(indexedDBPath, '*.leveldb');
rimraf.sync(pattern);
indexedDBPath = null;
}
const IDENTITY_KEYS_TABLE = 'identityKeys';
async function createOrUpdateIdentityKey(data) {
return createOrUpdate(IDENTITY_KEYS_TABLE, data);
}
async function getIdentityKeyById(id) {
return getById(IDENTITY_KEYS_TABLE, id);
}
async function bulkAddIdentityKeys(array) {
return bulkAdd(IDENTITY_KEYS_TABLE, array);
}
async function removeIdentityKeyById(id) {
return removeById(IDENTITY_KEYS_TABLE, id);
}
async function removeAllIdentityKeys() {
return removeAllFromTable(IDENTITY_KEYS_TABLE);
}
async function getAllIdentityKeys() {
return getAllFromTable(IDENTITY_KEYS_TABLE);
}
const PRE_KEYS_TABLE = 'preKeys';
async function createOrUpdatePreKey(data) {
return createOrUpdate(PRE_KEYS_TABLE, data);
}
async function getPreKeyById(id) {
return getById(PRE_KEYS_TABLE, id);
}
async function bulkAddPreKeys(array) {
return bulkAdd(PRE_KEYS_TABLE, array);
}
async function removePreKeyById(id) {
return removeById(PRE_KEYS_TABLE, id);
}
async function removeAllPreKeys() {
return removeAllFromTable(PRE_KEYS_TABLE);
}
async function getAllPreKeys() {
return getAllFromTable(PRE_KEYS_TABLE);
}
const SIGNED_PRE_KEYS_TABLE = 'signedPreKeys';
async function createOrUpdateSignedPreKey(data) {
return createOrUpdate(SIGNED_PRE_KEYS_TABLE, data);
}
async function getSignedPreKeyById(id) {
return getById(SIGNED_PRE_KEYS_TABLE, id);
}
async function getAllSignedPreKeys() {
const rows = await db.all('SELECT json FROM signedPreKeys ORDER BY id ASC;');
return map(rows, row => jsonToObject(row.json));
}
async function bulkAddSignedPreKeys(array) {
return bulkAdd(SIGNED_PRE_KEYS_TABLE, array);
}
async function removeSignedPreKeyById(id) {
return removeById(SIGNED_PRE_KEYS_TABLE, id);
}
async function removeAllSignedPreKeys() {
return removeAllFromTable(SIGNED_PRE_KEYS_TABLE);
}
const ITEMS_TABLE = 'items';
async function createOrUpdateItem(data) {
return createOrUpdate(ITEMS_TABLE, data);
}
async function getItemById(id) {
return getById(ITEMS_TABLE, id);
}
async function getAllItems() {
const rows = await db.all('SELECT json FROM items ORDER BY id ASC;');
return map(rows, row => jsonToObject(row.json));
}
async function bulkAddItems(array) {
return bulkAdd(ITEMS_TABLE, array);
}
async function removeItemById(id) {
return removeById(ITEMS_TABLE, id);
}
async function removeAllItems() {
return removeAllFromTable(ITEMS_TABLE);
}
const SESSIONS_TABLE = 'sessions';
async function createOrUpdateSession(data) {
const { id, number } = data;
if (!id) {
throw new Error(
'createOrUpdateSession: Provided data did not have a truthy id'
);
}
if (!number) {
throw new Error(
'createOrUpdateSession: Provided data did not have a truthy number'
);
}
await db.run(
`INSERT OR REPLACE INTO sessions (
id,
number,
json
) values (
$id,
$number,
$json
)`,
{
$id: id,
$number: number,
$json: objectToJSON(data),
}
);
}
async function createOrUpdateSessions(array) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([...map(array, item => createOrUpdateSession(item))]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
createOrUpdateSessions.needsSerial = true;
async function getSessionById(id) {
return getById(SESSIONS_TABLE, id);
}
async function getSessionsByNumber(number) {
const rows = await db.all('SELECT * FROM sessions WHERE number = $number;', {
$number: number,
});
return map(rows, row => jsonToObject(row.json));
}
async function bulkAddSessions(array) {
return bulkAdd(SESSIONS_TABLE, array);
}
async function removeSessionById(id) {
return removeById(SESSIONS_TABLE, id);
}
async function removeSessionsByNumber(number) {
await db.run('DELETE FROM sessions WHERE number = $number;', {
$number: number,
});
}
async function removeAllSessions() {
return removeAllFromTable(SESSIONS_TABLE);
}
async function getAllSessions() {
return getAllFromTable(SESSIONS_TABLE);
}
async function createOrUpdate(table, data) {
const { id } = data;
if (!id) {
throw new Error('createOrUpdate: Provided data did not have a truthy id');
}
await db.run(
`INSERT OR REPLACE INTO ${table} (
id,
json
) values (
$id,
$json
)`,
{
$id: id,
$json: objectToJSON(data),
}
);
}
async function bulkAdd(table, array) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([...map(array, data => createOrUpdate(table, data))]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
async function getById(table, id) {
const row = await db.get(`SELECT * FROM ${table} WHERE id = $id;`, {
$id: id,
});
if (!row) {
return null;
}
return jsonToObject(row.json);
}
async function removeById(table, id) {
if (!Array.isArray(id)) {
await db.run(`DELETE FROM ${table} WHERE id = $id;`, { $id: id });
return;
}
if (!id.length) {
throw new Error('removeById: No ids to delete!');
}
// Our node interface doesn't seem to allow you to replace one single ? with an array
await db.run(
`DELETE FROM ${table} WHERE id IN ( ${id.map(() => '?').join(', ')} );`,
id
);
}
async function removeAllFromTable(table) {
await db.run(`DELETE FROM ${table};`);
}
async function getAllFromTable(table) {
const rows = await db.all(`SELECT json FROM ${table};`);
return rows.map(row => jsonToObject(row.json));
}
// Conversations
async function getConversationCount() {
const row = await db.get('SELECT count(*) from conversations;');
if (!row) {
throw new Error(
'getConversationCount: Unable to get count of conversations'
);
}
return row['count(*)'];
}
async function saveConversation(data) {
// eslint-disable-next-line camelcase
const { id, active_at, type, members, name, profileName } = data;
await db.run(
`INSERT INTO conversations (
id,
json,
active_at,
type,
members,
name,
profileName
) values (
$id,
$json,
$active_at,
$type,
$members,
$name,
$profileName
);`,
{
$id: id,
$json: objectToJSON(data),
$active_at: active_at,
$type: type,
$members: members ? members.join(' ') : null,
$name: name,
$profileName: profileName,
}
);
}
async function saveConversations(arrayOfConversations) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([
...map(arrayOfConversations, conversation =>
saveConversation(conversation)
),
]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
saveConversations.needsSerial = true;
async function updateConversation(data) {
// eslint-disable-next-line camelcase
const { id, active_at, type, members, name, profileName } = data;
await db.run(
`UPDATE conversations SET
json = $json,
active_at = $active_at,
type = $type,
members = $members,
name = $name,
profileName = $profileName
WHERE id = $id;`,
{
$id: id,
$json: objectToJSON(data),
$active_at: active_at,
$type: type,
$members: members ? members.join(' ') : null,
$name: name,
$profileName: profileName,
}
);
}
async function updateConversations(array) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([...map(array, item => updateConversation(item))]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
updateConversations.needsSerial = true;
async function removeConversation(id) {
if (!Array.isArray(id)) {
await db.run('DELETE FROM conversations WHERE id = $id;', { $id: id });
return;
}
if (!id.length) {
throw new Error('removeConversation: No ids to delete!');
}
// Our node interface doesn't seem to allow you to replace one single ? with an array
await db.run(
`DELETE FROM conversations WHERE id IN ( ${id
.map(() => '?')
.join(', ')} );`,
id
);
}
async function getConversationById(id) {
const row = await db.get('SELECT * FROM conversations WHERE id = $id;', {
$id: id,
});
if (!row) {
return null;
}
return jsonToObject(row.json);
}
async function getAllConversations() {
const rows = await db.all('SELECT json FROM conversations ORDER BY id ASC;');
return map(rows, row => jsonToObject(row.json));
}
async function getAllConversationIds() {
const rows = await db.all('SELECT id FROM conversations ORDER BY id ASC;');
return map(rows, row => row.id);
}
async function getAllPrivateConversations() {
const rows = await db.all(
`SELECT json FROM conversations WHERE
type = 'private'
ORDER BY id ASC;`
);
return map(rows, row => jsonToObject(row.json));
}
async function getAllGroupsInvolvingId(id) {
const rows = await db.all(
`SELECT json FROM conversations WHERE
type = 'group' AND
members LIKE $id
ORDER BY id ASC;`,
{
$id: `%${id}%`,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function searchConversations(query, { limit } = {}) {
const rows = await db.all(
`SELECT json FROM conversations WHERE
(
id LIKE $id OR
name LIKE $name OR
profileName LIKE $profileName
)
ORDER BY active_at DESC
LIMIT $limit`,
{
$id: `%${query}%`,
$name: `%${query}%`,
$profileName: `%${query}%`,
$limit: limit || 100,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function searchMessages(query, { limit } = {}) {
const rows = await db.all(
`SELECT
messages.json,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE
messages_fts match $query
ORDER BY messages.received_at DESC
LIMIT $limit;`,
{
$query: query,
$limit: limit || 500,
}
);
return map(rows, row => ({
json: row.json,
snippet: row.snippet,
}));
}
async function searchMessagesInConversation(
query,
conversationId,
{ limit } = {}
) {
const rows = await db.all(
`SELECT
messages.json,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE
messages_fts match $query AND
messages.conversationId = $conversationId
ORDER BY messages.received_at DESC
LIMIT $limit;`,
{
$query: query,
$conversationId: conversationId,
$limit: limit || 100,
}
);
return map(rows, row => ({
json: row.json,
snippet: row.snippet,
}));
}
async function getMessageCount() {
const row = await db.get('SELECT count(*) from messages;');
if (!row) {
throw new Error('getMessageCount: Unable to get count of messages');
}
return row['count(*)'];
}
async function saveMessage(data, { forceSave } = {}) {
const {
body,
conversationId,
// eslint-disable-next-line camelcase
expires_at,
hasAttachments,
hasFileAttachments,
hasVisualMediaAttachments,
id,
isErased,
isViewOnce,
// eslint-disable-next-line camelcase
received_at,
schemaVersion,
// eslint-disable-next-line camelcase
sent_at,
source,
sourceDevice,
type,
unread,
expireTimer,
expirationStartTimestamp,
} = data;
const payload = {
$id: id,
$json: objectToJSON(data),
$body: body,
$conversationId: conversationId,
$expirationStartTimestamp: expirationStartTimestamp,
$expires_at: expires_at,
$expireTimer: expireTimer,
$hasAttachments: hasAttachments,
$hasFileAttachments: hasFileAttachments,
$hasVisualMediaAttachments: hasVisualMediaAttachments,
$isErased: isErased,
$isViewOnce: isViewOnce,
$received_at: received_at,
$schemaVersion: schemaVersion,
$sent_at: sent_at,
$source: source,
$sourceDevice: sourceDevice,
$type: type,
$unread: unread,
};
if (id && !forceSave) {
await db.run(
`UPDATE messages SET
id = $id,
json = $json,
body = $body,
conversationId = $conversationId,
expirationStartTimestamp = $expirationStartTimestamp,
expires_at = $expires_at,
expireTimer = $expireTimer,
hasAttachments = $hasAttachments,
hasFileAttachments = $hasFileAttachments,
hasVisualMediaAttachments = $hasVisualMediaAttachments,
isErased = $isErased,
isViewOnce = $isViewOnce,
received_at = $received_at,
schemaVersion = $schemaVersion,
sent_at = $sent_at,
source = $source,
sourceDevice = $sourceDevice,
type = $type,
unread = $unread
WHERE id = $id;`,
payload
);
return id;
}
const toCreate = {
...data,
id: id || generateUUID(),
};
await db.run(
`INSERT INTO messages (
id,
json,
body,
conversationId,
expirationStartTimestamp,
expires_at,
expireTimer,
hasAttachments,
hasFileAttachments,
hasVisualMediaAttachments,
isErased,
isViewOnce,
received_at,
schemaVersion,
sent_at,
source,
sourceDevice,
type,
unread
) values (
$id,
$json,
$body,
$conversationId,
$expirationStartTimestamp,
$expires_at,
$expireTimer,
$hasAttachments,
$hasFileAttachments,
$hasVisualMediaAttachments,
$isErased,
$isViewOnce,
$received_at,
$schemaVersion,
$sent_at,
$source,
$sourceDevice,
$type,
$unread
);`,
{
...payload,
$id: toCreate.id,
$json: objectToJSON(toCreate),
}
);
return toCreate.id;
}
async function saveMessages(arrayOfMessages, { forceSave } = {}) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([
...map(arrayOfMessages, message => saveMessage(message, { forceSave })),
]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
saveMessages.needsSerial = true;
async function removeMessage(id) {
if (!Array.isArray(id)) {
await db.run('DELETE FROM messages WHERE id = $id;', { $id: id });
return;
}
if (!id.length) {
throw new Error('removeMessages: No ids to delete!');
}
// Our node interface doesn't seem to allow you to replace one single ? with an array
await db.run(
`DELETE FROM messages WHERE id IN ( ${id.map(() => '?').join(', ')} );`,
id
);
}
async function getMessageById(id) {
const row = await db.get('SELECT * FROM messages WHERE id = $id;', {
$id: id,
});
if (!row) {
return null;
}
return jsonToObject(row.json);
}
async function getAllMessages() {
const rows = await db.all('SELECT json FROM messages ORDER BY id ASC;');
return map(rows, row => jsonToObject(row.json));
}
async function getAllMessageIds() {
const rows = await db.all('SELECT id FROM messages ORDER BY id ASC;');
return map(rows, row => row.id);
}
// eslint-disable-next-line camelcase
async function getMessageBySender({ source, sourceDevice, sent_at }) {
const rows = await db.all(
`SELECT json FROM messages WHERE
source = $source AND
sourceDevice = $sourceDevice AND
sent_at = $sent_at;`,
{
$source: source,
$sourceDevice: sourceDevice,
$sent_at: sent_at,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getUnreadByConversation(conversationId) {
const rows = await db.all(
`SELECT json FROM messages WHERE
unread = $unread AND
conversationId = $conversationId
ORDER BY received_at DESC;`,
{
$unread: 1,
$conversationId: conversationId,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getOlderMessagesByConversation(
conversationId,
{ limit = 100, receivedAt = Number.MAX_VALUE } = {}
) {
const rows = await db.all(
`SELECT json FROM messages WHERE
conversationId = $conversationId AND
received_at < $received_at
ORDER BY received_at DESC
LIMIT $limit;`,
{
$conversationId: conversationId,
$received_at: receivedAt,
$limit: limit,
}
);
return rows.reverse();
}
async function getNewerMessagesByConversation(
conversationId,
{ limit = 100, receivedAt = 0 } = {}
) {
const rows = await db.all(
`SELECT json FROM messages WHERE
conversationId = $conversationId AND
received_at > $received_at
ORDER BY received_at ASC
LIMIT $limit;`,
{
$conversationId: conversationId,
$received_at: receivedAt,
$limit: limit,
}
);
return rows;
}
async function getOldestMessageForConversation(conversationId) {
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId
ORDER BY received_at ASC
LIMIT 1;`,
{
$conversationId: conversationId,
}
);
if (!row) {
return null;
}
return row;
}
async function getNewestMessageForConversation(conversationId) {
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId
ORDER BY received_at DESC
LIMIT 1;`,
{
$conversationId: conversationId,
}
);
if (!row) {
return null;
}
return row;
}
async function getOldestUnreadMessageForConversation(conversationId) {
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId AND
unread = 1
ORDER BY received_at ASC
LIMIT 1;`,
{
$conversationId: conversationId,
}
);
if (!row) {
return null;
}
return row;
}
async function getTotalUnreadForConversation(conversationId) {
const row = await db.get(
`SELECT count(id) from messages WHERE
conversationId = $conversationId AND
unread = 1;
`,
{
$conversationId: conversationId,
}
);
if (!row) {
throw new Error('getTotalUnreadForConversation: Unable to get count');
}
return row['count(id)'];
}
async function getMessageMetricsForConversation(conversationId) {
const results = await Promise.all([
getOldestMessageForConversation(conversationId),
getNewestMessageForConversation(conversationId),
getOldestUnreadMessageForConversation(conversationId),
getTotalUnreadForConversation(conversationId),
]);
const [oldest, newest, oldestUnread, totalUnread] = results;
return {
oldest: oldest ? pick(oldest, ['received_at', 'id']) : null,
newest: newest ? pick(newest, ['received_at', 'id']) : null,
oldestUnread: oldestUnread
? pick(oldestUnread, ['received_at', 'id'])
: null,
totalUnread,
};
}
getMessageMetricsForConversation.needsSerial = true;
async function getMessagesBySentAt(sentAt) {
const rows = await db.all(
`SELECT * FROM messages
WHERE sent_at = $sent_at
ORDER BY received_at DESC;`,
{
$sent_at: sentAt,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getExpiredMessages() {
const now = Date.now();
const rows = await db.all(
`SELECT json FROM messages WHERE
expires_at IS NOT NULL AND
expires_at <= $expires_at
ORDER BY expires_at ASC;`,
{
$expires_at: now,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getOutgoingWithoutExpiresAt() {
const rows = await db.all(`
SELECT json FROM messages
WHERE
expireTimer > 0 AND
expires_at IS NULL AND
type IS 'outgoing'
ORDER BY expires_at ASC;
`);
return map(rows, row => jsonToObject(row.json));
}
async function getNextExpiringMessage() {
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(`
SELECT json FROM messages
WHERE expires_at > 0
ORDER BY expires_at ASC
LIMIT 1;
`);
return map(rows, row => jsonToObject(row.json));
}
async function getNextTapToViewMessageToAgeOut() {
const rows = await db.all(`
SELECT json FROM messages
WHERE
isViewOnce = 1
AND (isErased IS NULL OR isErased != 1)
ORDER BY received_at ASC
LIMIT 1;
`);
if (!rows || rows.length < 1) {
return null;
}
return jsonToObject(rows[0].json);
}
async function getTapToViewMessagesNeedingErase() {
const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000;
const rows = await db.all(
`SELECT json FROM messages
WHERE
isViewOnce = 1
AND (isErased IS NULL OR isErased != 1)
AND received_at <= $THIRTY_DAYS_AGO
ORDER BY received_at ASC;`,
{
$THIRTY_DAYS_AGO: THIRTY_DAYS_AGO,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function saveUnprocessed(data, { forceSave } = {}) {
const { id, timestamp, version, attempts, envelope } = data;
if (!id) {
throw new Error('saveUnprocessed: id was falsey');
}
if (forceSave) {
await db.run(
`INSERT INTO unprocessed (
id,
timestamp,
version,
attempts,
envelope
) values (
$id,
$timestamp,
$version,
$attempts,
$envelope
);`,
{
$id: id,
$timestamp: timestamp,
$version: version,
$attempts: attempts,
$envelope: envelope,
}
);
return id;
}
await db.run(
`UPDATE unprocessed SET
timestamp = $timestamp,
version = $version,
attempts = $attempts,
envelope = $envelope
WHERE id = $id;`,
{
$id: id,
$timestamp: timestamp,
$version: version,
$attempts: attempts,
$envelope: envelope,
}
);
return id;
}
async function saveUnprocesseds(arrayOfUnprocessed, { forceSave } = {}) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([
...map(arrayOfUnprocessed, unprocessed =>
saveUnprocessed(unprocessed, { forceSave })
),
]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
saveUnprocesseds.needsSerial = true;
async function updateUnprocessedAttempts(id, attempts) {
await db.run('UPDATE unprocessed SET attempts = $attempts WHERE id = $id;', {
$id: id,
$attempts: attempts,
});
}
async function updateUnprocessedWithData(id, data = {}) {
const { source, sourceDevice, serverTimestamp, decrypted } = data;
await db.run(
`UPDATE unprocessed SET
source = $source,
sourceDevice = $sourceDevice,
serverTimestamp = $serverTimestamp,
decrypted = $decrypted
WHERE id = $id;`,
{
$id: id,
$source: source,
$sourceDevice: sourceDevice,
$serverTimestamp: serverTimestamp,
$decrypted: decrypted,
}
);
}
async function updateUnprocessedsWithData(arrayOfUnprocessed) {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([
...map(arrayOfUnprocessed, ({ id, data }) =>
updateUnprocessedWithData(id, data)
),
]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
updateUnprocessedsWithData.needsSerial = true;
async function getUnprocessedById(id) {
const row = await db.get('SELECT * FROM unprocessed WHERE id = $id;', {
$id: id,
});
return row;
}
async function getUnprocessedCount() {
const row = await db.get('SELECT count(*) from unprocessed;');
if (!row) {
throw new Error('getMessageCount: Unable to get count of unprocessed');
}
return row['count(*)'];
}
async function getAllUnprocessed() {
const rows = await db.all(
'SELECT * FROM unprocessed ORDER BY timestamp ASC;'
);
return rows;
}
async function removeUnprocessed(id) {
if (!Array.isArray(id)) {
await db.run('DELETE FROM unprocessed WHERE id = $id;', { $id: id });
return;
}
if (!id.length) {
throw new Error('removeUnprocessed: No ids to delete!');
}
// Our node interface doesn't seem to allow you to replace one single ? with an array
await db.run(
`DELETE FROM unprocessed WHERE id IN ( ${id.map(() => '?').join(', ')} );`,
id
);
}
async function removeAllUnprocessed() {
await db.run('DELETE FROM unprocessed;');
}
// Attachment Downloads
const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads';
async function getNextAttachmentDownloadJobs(limit, options = {}) {
const timestamp = options.timestamp || Date.now();
const rows = await db.all(
`SELECT json FROM attachment_downloads
WHERE pending = 0 AND timestamp < $timestamp
ORDER BY timestamp DESC
LIMIT $limit;`,
{
$limit: limit,
$timestamp: timestamp,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function saveAttachmentDownloadJob(job) {
const { id, pending, timestamp } = job;
if (!id) {
throw new Error(
'saveAttachmentDownloadJob: Provided job did not have a truthy id'
);
}
await db.run(
`INSERT OR REPLACE INTO attachment_downloads (
id,
pending,
timestamp,
json
) values (
$id,
$pending,
$timestamp,
$json
)`,
{
$id: id,
$pending: pending,
$timestamp: timestamp,
$json: objectToJSON(job),
}
);
}
async function setAttachmentDownloadJobPending(id, pending) {
await db.run(
'UPDATE attachment_downloads SET pending = $pending WHERE id = $id;',
{
$id: id,
$pending: pending,
}
);
}
async function resetAttachmentDownloadPending() {
await db.run(
'UPDATE attachment_downloads SET pending = 0 WHERE pending != 0;'
);
}
async function removeAttachmentDownloadJob(id) {
return removeById(ATTACHMENT_DOWNLOADS_TABLE, id);
}
async function removeAllAttachmentDownloadJobs() {
return removeAllFromTable(ATTACHMENT_DOWNLOADS_TABLE);
}
// Stickers
async function createOrUpdateStickerPack(pack) {
const {
attemptedStatus,
author,
coverStickerId,
createdAt,
downloadAttempts,
id,
installedAt,
key,
lastUsed,
status,
stickerCount,
title,
} = pack;
if (!id) {
throw new Error(
'createOrUpdateStickerPack: Provided data did not have a truthy id'
);
}
const rows = await db.all('SELECT id FROM sticker_packs WHERE id = $id;', {
$id: id,
});
const payload = {
$attemptedStatus: attemptedStatus,
$author: author,
$coverStickerId: coverStickerId,
$createdAt: createdAt || Date.now(),
$downloadAttempts: downloadAttempts || 1,
$id: id,
$installedAt: installedAt,
$key: key,
$lastUsed: lastUsed || null,
$status: status,
$stickerCount: stickerCount,
$title: title,
};
if (rows && rows.length) {
await db.run(
`UPDATE sticker_packs SET
attemptedStatus = $attemptedStatus,
author = $author,
coverStickerId = $coverStickerId,
createdAt = $createdAt,
downloadAttempts = $downloadAttempts,
installedAt = $installedAt,
key = $key,
lastUsed = $lastUsed,
status = $status,
stickerCount = $stickerCount,
title = $title
WHERE id = $id;`,
payload
);
return;
}
await db.run(
`INSERT INTO sticker_packs (
attemptedStatus,
author,
coverStickerId,
createdAt,
downloadAttempts,
id,
installedAt,
key,
lastUsed,
status,
stickerCount,
title
) values (
$attemptedStatus,
$author,
$coverStickerId,
$createdAt,
$downloadAttempts,
$id,
$installedAt,
$key,
$lastUsed,
$status,
$stickerCount,
$title
)`,
payload
);
}
async function updateStickerPackStatus(id, status, options) {
// Strange, but an undefined parameter gets coerced into null via ipc
const timestamp = (options || {}).timestamp || Date.now();
const installedAt = status === 'installed' ? timestamp : null;
await db.run(
`UPDATE sticker_packs
SET status = $status, installedAt = $installedAt
WHERE id = $id;
)`,
{
$id: id,
$status: status,
$installedAt: installedAt,
}
);
}
async function createOrUpdateSticker(sticker) {
const {
emoji,
height,
id,
isCoverOnly,
lastUsed,
packId,
path,
width,
} = sticker;
if (!isNumber(id)) {
throw new Error(
'createOrUpdateSticker: Provided data did not have a numeric id'
);
}
if (!packId) {
throw new Error(
'createOrUpdateSticker: Provided data did not have a truthy id'
);
}
await db.run(
`INSERT OR REPLACE INTO stickers (
emoji,
height,
id,
isCoverOnly,
lastUsed,
packId,
path,
width
) values (
$emoji,
$height,
$id,
$isCoverOnly,
$lastUsed,
$packId,
$path,
$width
)`,
{
$emoji: emoji,
$height: height,
$id: id,
$isCoverOnly: isCoverOnly,
$lastUsed: lastUsed,
$packId: packId,
$path: path,
$width: width,
}
);
}
async function updateStickerLastUsed(packId, stickerId, lastUsed) {
await db.run(
`UPDATE stickers
SET lastUsed = $lastUsed
WHERE id = $id AND packId = $packId;`,
{
$id: stickerId,
$packId: packId,
$lastUsed: lastUsed,
}
);
await db.run(
`UPDATE sticker_packs
SET lastUsed = $lastUsed
WHERE id = $id;`,
{
$id: packId,
$lastUsed: lastUsed,
}
);
}
async function addStickerPackReference(messageId, packId) {
if (!messageId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy messageId'
);
}
if (!packId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy packId'
);
}
await db.run(
`INSERT OR REPLACE INTO sticker_references (
messageId,
packId
) values (
$messageId,
$packId
)`,
{
$messageId: messageId,
$packId: packId,
}
);
}
async function deleteStickerPackReference(messageId, packId) {
if (!messageId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy messageId'
);
}
if (!packId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy packId'
);
}
try {
// We use an immediate transaction here to immediately acquire an exclusive lock,
// which would normally only happen when we did our first write.
// We need this to ensure that our five queries are all atomic, with no other changes
// happening while we do it:
// 1. Delete our target messageId/packId references
// 2. Check the number of references still pointing at packId
// 3. If that number is zero, get pack from sticker_packs database
// 4. If it's not installed, then grab all of its sticker paths
// 5. If it's not installed, then sticker pack (which cascades to all stickers and
// references)
await db.run('BEGIN IMMEDIATE TRANSACTION;');
await db.run(
`DELETE FROM sticker_references
WHERE messageId = $messageId AND packId = $packId;`,
{
$messageId: messageId,
$packId: packId,
}
);
const countRow = await db.get(
`SELECT count(*) FROM sticker_references
WHERE packId = $packId;`,
{ $packId: packId }
);
if (!countRow) {
throw new Error(
'deleteStickerPackReference: Unable to get count of references'
);
}
const count = countRow['count(*)'];
if (count > 0) {
await db.run('COMMIT TRANSACTION;');
return null;
}
const packRow = await db.get(
`SELECT status FROM sticker_packs
WHERE id = $packId;`,
{ $packId: packId }
);
if (!packRow) {
console.log('deleteStickerPackReference: did not find referenced pack');
await db.run('COMMIT TRANSACTION;');
return null;
}
const { status } = packRow;
if (status === 'installed') {
await db.run('COMMIT TRANSACTION;');
return null;
}
const stickerPathRows = await db.all(
`SELECT path FROM stickers
WHERE packId = $packId;`,
{
$packId: packId,
}
);
await db.run(
`DELETE FROM sticker_packs
WHERE id = $packId;`,
{ $packId: packId }
);
await db.run('COMMIT TRANSACTION;');
return (stickerPathRows || []).map(row => row.path);
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
deleteStickerPackReference.needsSerial = true;
async function deleteStickerPack(packId) {
if (!packId) {
throw new Error(
'deleteStickerPack: Provided data did not have a truthy packId'
);
}
try {
// We use an immediate transaction here to immediately acquire an exclusive lock,
// which would normally only happen when we did our first write.
// We need this to ensure that our two queries are atomic, with no other changes
// happening while we do it:
// 1. Grab all of target pack's sticker paths
// 2. Delete sticker pack (which cascades to all stickers and references)
await db.run('BEGIN IMMEDIATE TRANSACTION;');
const stickerPathRows = await db.all(
`SELECT path FROM stickers
WHERE packId = $packId;`,
{
$packId: packId,
}
);
await db.run(
`DELETE FROM sticker_packs
WHERE id = $packId;`,
{ $packId: packId }
);
await db.run('COMMIT TRANSACTION;');
return (stickerPathRows || []).map(row => row.path);
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
deleteStickerPack.needsSerial = true;
async function getStickerCount() {
const row = await db.get('SELECT count(*) from stickers;');
if (!row) {
throw new Error('getStickerCount: Unable to get count of stickers');
}
return row['count(*)'];
}
async function getAllStickerPacks() {
const rows = await db.all(
`SELECT * FROM sticker_packs
ORDER BY installedAt DESC, createdAt DESC`
);
return rows || [];
}
async function getAllStickers() {
const rows = await db.all(
`SELECT * FROM stickers
ORDER BY packId ASC, id ASC`
);
return rows || [];
}
async function getRecentStickers({ limit } = {}) {
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(
`SELECT stickers.* FROM stickers
JOIN sticker_packs on stickers.packId = sticker_packs.id
WHERE stickers.lastUsed > 0 AND sticker_packs.status = 'installed'
ORDER BY stickers.lastUsed DESC
LIMIT $limit`,
{
$limit: limit || 24,
}
);
return rows || [];
}
// Emojis
async function updateEmojiUsage(shortName, timeUsed = Date.now()) {
await db.run('BEGIN TRANSACTION;');
try {
const rows = await db.get(
'SELECT * FROM emojis WHERE shortName = $shortName;',
{
$shortName: shortName,
}
);
if (rows) {
await db.run(
'UPDATE emojis SET lastUsage = $timeUsed WHERE shortName = $shortName;',
{ $shortName: shortName, $timeUsed: timeUsed }
);
} else {
await db.run(
'INSERT INTO emojis(shortName, lastUsage) VALUES ($shortName, $timeUsed);',
{ $shortName: shortName, $timeUsed: timeUsed }
);
}
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
updateEmojiUsage.needsSerial = true;
async function getRecentEmojis(limit = 32) {
const rows = await db.all(
'SELECT * FROM emojis ORDER BY lastUsage DESC LIMIT $limit;',
{
$limit: limit,
}
);
return rows || [];
}
// All data in database
async function removeAll() {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([
db.run('DELETE FROM conversations;'),
db.run('DELETE FROM identityKeys;'),
db.run('DELETE FROM items;'),
db.run('DELETE FROM messages;'),
db.run('DELETE FROM preKeys;'),
db.run('DELETE FROM sessions;'),
db.run('DELETE FROM signedPreKeys;'),
db.run('DELETE FROM unprocessed;'),
db.run('DELETE FROM attachment_downloads;'),
db.run('DELETE FROM messages_fts;'),
db.run('DELETE FROM stickers;'),
db.run('DELETE FROM sticker_packs;'),
db.run('DELETE FROM sticker_references;'),
]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
removeAll.needsSerial = true;
// Anything that isn't user-visible data
async function removeAllConfiguration() {
await db.run('BEGIN TRANSACTION;');
try {
await Promise.all([
db.run('DELETE FROM identityKeys;'),
db.run('DELETE FROM items;'),
db.run('DELETE FROM preKeys;'),
db.run('DELETE FROM sessions;'),
db.run('DELETE FROM signedPreKeys;'),
db.run('DELETE FROM unprocessed;'),
]);
await db.run('COMMIT TRANSACTION;');
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
removeAllConfiguration.needsSerial = true;
async function getMessagesNeedingUpgrade(limit, { maxVersion }) {
const rows = await db.all(
`SELECT json FROM messages
WHERE schemaVersion IS NULL OR schemaVersion < $maxVersion
LIMIT $limit;`,
{
$maxVersion: maxVersion,
$limit: limit,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getMessagesWithVisualMediaAttachments(
conversationId,
{ limit }
) {
const rows = await db.all(
`SELECT json FROM messages WHERE
conversationId = $conversationId AND
hasVisualMediaAttachments = 1
ORDER BY received_at DESC
LIMIT $limit;`,
{
$conversationId: conversationId,
$limit: limit,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getMessagesWithFileAttachments(conversationId, { limit }) {
const rows = await db.all(
`SELECT json FROM messages WHERE
conversationId = $conversationId AND
hasFileAttachments = 1
ORDER BY received_at DESC
LIMIT $limit;`,
{
$conversationId: conversationId,
$limit: limit,
}
);
return map(rows, row => jsonToObject(row.json));
}
function getExternalFilesForMessage(message) {
const { attachments, contact, quote, preview, sticker } = message;
const files = [];
forEach(attachments, attachment => {
const { path: file, thumbnail, screenshot } = attachment;
if (file) {
files.push(file);
}
if (thumbnail && thumbnail.path) {
files.push(thumbnail.path);
}
if (screenshot && screenshot.path) {
files.push(screenshot.path);
}
});
if (quote && quote.attachments && quote.attachments.length) {
forEach(quote.attachments, attachment => {
const { thumbnail } = attachment;
if (thumbnail && thumbnail.path) {
files.push(thumbnail.path);
}
});
}
if (contact && contact.length) {
forEach(contact, item => {
const { avatar } = item;
if (avatar && avatar.avatar && avatar.avatar.path) {
files.push(avatar.avatar.path);
}
});
}
if (preview && preview.length) {
forEach(preview, item => {
const { image } = item;
if (image && image.path) {
files.push(image.path);
}
});
}
if (sticker && sticker.data && sticker.data.path) {
files.push(sticker.data.path);
if (sticker.data.thumbnail && sticker.data.thumbnail.path) {
files.push(sticker.data.thumbnail.path);
}
}
return files;
}
function getExternalFilesForConversation(conversation) {
const { avatar, profileAvatar } = conversation;
const files = [];
if (avatar && avatar.path) {
files.push(avatar.path);
}
if (profileAvatar && profileAvatar.path) {
files.push(profileAvatar.path);
}
return files;
}
function getExternalDraftFilesForConversation(conversation) {
const draftAttachments = conversation.draftAttachments || [];
const files = [];
forEach(draftAttachments, attachment => {
const { path: file, screenshotPath } = attachment;
if (file) {
files.push(file);
}
if (screenshotPath) {
files.push(screenshotPath);
}
});
return files;
}
async function removeKnownAttachments(allAttachments) {
const lookup = fromPairs(map(allAttachments, file => [file, true]));
const chunkSize = 50;
const total = await getMessageCount();
console.log(
`removeKnownAttachments: About to iterate through ${total} messages`
);
let count = 0;
let complete = false;
let id = '';
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const rows = await db.all(
`SELECT json FROM messages
WHERE id > $id
ORDER BY id ASC
LIMIT $chunkSize;`,
{
$id: id,
$chunkSize: chunkSize,
}
);
const messages = map(rows, row => jsonToObject(row.json));
forEach(messages, message => {
const externalFiles = getExternalFilesForMessage(message);
forEach(externalFiles, file => {
delete lookup[file];
});
});
const lastMessage = last(messages);
if (lastMessage) {
({ id } = lastMessage);
}
complete = messages.length < chunkSize;
count += messages.length;
}
console.log(`removeKnownAttachments: Done processing ${count} messages`);
complete = false;
count = 0;
// Though conversations.id is a string, this ensures that, when coerced, this
// value is still a string but it's smaller than every other string.
id = 0;
const conversationTotal = await getConversationCount();
console.log(
`removeKnownAttachments: About to iterate through ${conversationTotal} conversations`
);
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const rows = await db.all(
`SELECT json FROM conversations
WHERE id > $id
ORDER BY id ASC
LIMIT $chunkSize;`,
{
$id: id,
$chunkSize: chunkSize,
}
);
const conversations = map(rows, row => jsonToObject(row.json));
forEach(conversations, conversation => {
const externalFiles = getExternalFilesForConversation(conversation);
forEach(externalFiles, file => {
delete lookup[file];
});
});
const lastMessage = last(conversations);
if (lastMessage) {
({ id } = lastMessage);
}
complete = conversations.length < chunkSize;
count += conversations.length;
}
console.log(`removeKnownAttachments: Done processing ${count} conversations`);
return Object.keys(lookup);
}
async function removeKnownStickers(allStickers) {
const lookup = fromPairs(map(allStickers, file => [file, true]));
const chunkSize = 50;
const total = await getStickerCount();
console.log(
`removeKnownStickers: About to iterate through ${total} stickers`
);
let count = 0;
let complete = false;
let rowid = 0;
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const rows = await db.all(
`SELECT rowid, path FROM stickers
WHERE rowid > $rowid
ORDER BY rowid ASC
LIMIT $chunkSize;`,
{
$rowid: rowid,
$chunkSize: chunkSize,
}
);
const files = map(rows, row => row.path);
forEach(files, file => {
delete lookup[file];
});
const lastSticker = last(rows);
if (lastSticker) {
({ rowid } = lastSticker);
}
complete = rows.length < chunkSize;
count += rows.length;
}
console.log(`removeKnownStickers: Done processing ${count} stickers`);
return Object.keys(lookup);
}
async function removeKnownDraftAttachments(allStickers) {
const lookup = fromPairs(map(allStickers, file => [file, true]));
const chunkSize = 50;
const total = await getConversationCount();
console.log(
`removeKnownDraftAttachments: About to iterate through ${total} conversations`
);
let complete = false;
let count = 0;
// Though conversations.id is a string, this ensures that, when coerced, this
// value is still a string but it's smaller than every other string.
let id = 0;
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const rows = await db.all(
`SELECT json FROM conversations
WHERE id > $id
ORDER BY id ASC
LIMIT $chunkSize;`,
{
$id: id,
$chunkSize: chunkSize,
}
);
const conversations = map(rows, row => jsonToObject(row.json));
forEach(conversations, conversation => {
const externalFiles = getExternalDraftFilesForConversation(conversation);
forEach(externalFiles, file => {
delete lookup[file];
});
});
const lastMessage = last(conversations);
if (lastMessage) {
({ id } = lastMessage);
}
complete = conversations.length < chunkSize;
count += conversations.length;
}
console.log(
`removeKnownDraftAttachments: Done processing ${count} conversations`
);
return Object.keys(lookup);
}