Use UUIDs in group database schema

This commit is contained in:
Fedor Indutny 2021-10-26 15:59:08 -07:00 committed by GitHub
parent 74fde10ff5
commit 63fcdbe787
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 4530 additions and 3664 deletions

View file

@ -0,0 +1,448 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import type { LoggerType } from '../../types/Logging';
import { isValidUuid } from '../../types/UUID';
import { assertSync } from '../../util/assert';
import Helpers from '../../textsecure/Helpers';
import { createOrUpdate, getById, removeById } from '../util';
import type { EmptyQuery, Query } from '../util';
import type { ItemKeyType } from '../Interface';
function getOurUuid(db: Database): string | undefined {
const UUID_ID: ItemKeyType = 'uuid_id';
const row: { json: string } | undefined = db
.prepare<Query>('SELECT json FROM items WHERE id = $id;')
.get({ id: UUID_ID });
if (!row) {
return undefined;
}
const { value } = JSON.parse(row.json);
const [ourUuid] = Helpers.unencodeNumber(String(value).toLowerCase());
return ourUuid;
}
export default function updateToSchemaVersion41(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 41) {
return;
}
const getConversationUuid = db
.prepare<Query>(
`
SELECT uuid
FROM
conversations
WHERE
id = $conversationId
`
)
.pluck();
const getConversationStats = db.prepare<Query>(
`
SELECT uuid, e164, active_at
FROM
conversations
WHERE
id = $conversationId
`
);
const compareConvoRecency = (a: string, b: string): number => {
const aStats = getConversationStats.get({ conversationId: a });
const bStats = getConversationStats.get({ conversationId: b });
const isAComplete = Boolean(aStats?.uuid && aStats?.e164);
const isBComplete = Boolean(bStats?.uuid && bStats?.e164);
if (!isAComplete && !isBComplete) {
return 0;
}
if (!isAComplete) {
return -1;
}
if (!isBComplete) {
return 1;
}
return aStats.active_at - bStats.active_at;
};
const clearSessionsAndKeys = () => {
// ts/background.ts will ask user to relink so all that matters here is
// to maintain an invariant:
//
// After this migration all sessions and keys are prefixed by
// "uuid:".
db.exec(
`
DELETE FROM senderKeys;
DELETE FROM sessions;
DELETE FROM signedPreKeys;
DELETE FROM preKeys;
`
);
assertSync(removeById<string>(db, 'items', 'identityKey'));
assertSync(removeById<string>(db, 'items', 'registrationId'));
};
const moveIdentityKeyToMap = (ourUuid: string) => {
type IdentityKeyType = {
privKey: string;
publicKey: string;
};
const identityKey = assertSync(
getById<string, { value: IdentityKeyType }>(db, 'items', 'identityKey')
);
type RegistrationId = number;
const registrationId = assertSync(
getById<string, { value: RegistrationId }>(db, 'items', 'registrationId')
);
if (identityKey) {
assertSync(
createOrUpdate<ItemKeyType>(db, 'items', {
id: 'identityKeyMap',
value: {
[ourUuid]: identityKey.value,
},
})
);
}
if (registrationId) {
assertSync(
createOrUpdate<ItemKeyType>(db, 'items', {
id: 'registrationIdMap',
value: {
[ourUuid]: registrationId.value,
},
})
);
}
db.exec(
`
DELETE FROM items WHERE id = "identityKey" OR id = "registrationId";
`
);
};
const prefixKeys = (ourUuid: string) => {
for (const table of ['signedPreKeys', 'preKeys']) {
// Update id to include suffix, add `ourUuid` and `keyId` fields.
db.prepare<Query>(
`
UPDATE ${table}
SET
id = $ourUuid || ':' || id,
json = json_set(
json,
'$.id',
$ourUuid || ':' || json_extract(json, '$.id'),
'$.keyId',
json_extract(json, '$.id'),
'$.ourUuid',
$ourUuid
)
`
).run({ ourUuid });
}
};
const updateSenderKeys = (ourUuid: string) => {
const senderKeys: ReadonlyArray<{
id: string;
senderId: string;
lastUpdatedDate: number;
}> = db
.prepare<EmptyQuery>(
'SELECT id, senderId, lastUpdatedDate FROM senderKeys'
)
.all();
logger.info(`Updating ${senderKeys.length} sender keys`);
const updateSenderKey = db.prepare<Query>(
`
UPDATE senderKeys
SET
id = $newId,
senderId = $newSenderId
WHERE
id = $id
`
);
const deleteSenderKey = db.prepare<Query>(
'DELETE FROM senderKeys WHERE id = $id'
);
const pastKeys = new Map<
string,
{
conversationId: string;
lastUpdatedDate: number;
}
>();
let updated = 0;
let deleted = 0;
let skipped = 0;
for (const { id, senderId, lastUpdatedDate } of senderKeys) {
const [conversationId] = Helpers.unencodeNumber(senderId);
const uuid = getConversationUuid.get({ conversationId });
if (!uuid) {
deleted += 1;
deleteSenderKey.run({ id });
continue;
}
const newId = `${ourUuid}:${id.replace(conversationId, uuid)}`;
const existing = pastKeys.get(newId);
// We are going to delete on of the keys anyway
if (existing) {
skipped += 1;
} else {
updated += 1;
}
const isOlder =
existing &&
(lastUpdatedDate < existing.lastUpdatedDate ||
compareConvoRecency(conversationId, existing.conversationId) < 0);
if (isOlder) {
deleteSenderKey.run({ id });
continue;
} else if (existing) {
deleteSenderKey.run({ id: newId });
}
pastKeys.set(newId, { conversationId, lastUpdatedDate });
updateSenderKey.run({
id,
newId,
newSenderId: `${senderId.replace(conversationId, uuid)}`,
});
}
logger.info(
`Updated ${senderKeys.length} sender keys: ` +
`updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}`
);
};
const updateSessions = (ourUuid: string) => {
// Use uuid instead of conversation id in existing sesions and prefix id
// with ourUuid.
//
// Set ourUuid column and field in json
const allSessions = db
.prepare<EmptyQuery>('SELECT id, conversationId FROM SESSIONS')
.all();
logger.info(`Updating ${allSessions.length} sessions`);
const updateSession = db.prepare<Query>(
`
UPDATE sessions
SET
id = $newId,
ourUuid = $ourUuid,
uuid = $uuid,
json = json_set(
sessions.json,
'$.id',
$newId,
'$.uuid',
$uuid,
'$.ourUuid',
$ourUuid
)
WHERE
id = $id
`
);
const deleteSession = db.prepare<Query>(
'DELETE FROM sessions WHERE id = $id'
);
const pastSessions = new Map<
string,
{
conversationId: string;
}
>();
let updated = 0;
let deleted = 0;
let skipped = 0;
for (const { id, conversationId } of allSessions) {
const uuid = getConversationUuid.get({ conversationId });
if (!uuid) {
deleted += 1;
deleteSession.run({ id });
continue;
}
const newId = `${ourUuid}:${id.replace(conversationId, uuid)}`;
const existing = pastSessions.get(newId);
// We are going to delete on of the keys anyway
if (existing) {
skipped += 1;
} else {
updated += 1;
}
const isOlder =
existing &&
compareConvoRecency(conversationId, existing.conversationId) < 0;
if (isOlder) {
deleteSession.run({ id });
continue;
} else if (existing) {
deleteSession.run({ id: newId });
}
pastSessions.set(newId, { conversationId });
updateSession.run({
id,
newId,
uuid,
ourUuid,
});
}
logger.info(
`Updated ${allSessions.length} sessions: ` +
`updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}`
);
};
const updateIdentityKeys = () => {
const identityKeys: ReadonlyArray<{
id: string;
}> = db.prepare<EmptyQuery>('SELECT id FROM identityKeys').all();
logger.info(`Updating ${identityKeys.length} identity keys`);
const updateIdentityKey = db.prepare<Query>(
`
UPDATE identityKeys
SET
id = $newId,
json = json_set(
identityKeys.json,
'$.id',
$newId
)
WHERE
id = $id
`
);
let migrated = 0;
for (const { id } of identityKeys) {
const uuid = getConversationUuid.get({ conversationId: id });
let newId: string;
if (uuid) {
migrated += 1;
newId = uuid;
} else {
newId = `conversation:${id}`;
}
updateIdentityKey.run({ id, newId });
}
logger.info(`Migrated ${migrated} identity keys`);
};
db.transaction(() => {
db.exec(
`
-- Change type of 'id' column from INTEGER to STRING
ALTER TABLE preKeys
RENAME TO old_preKeys;
ALTER TABLE signedPreKeys
RENAME TO old_signedPreKeys;
CREATE TABLE preKeys(
id STRING PRIMARY KEY ASC,
json TEXT
);
CREATE TABLE signedPreKeys(
id STRING PRIMARY KEY ASC,
json TEXT
);
-- sqlite handles the type conversion
INSERT INTO preKeys SELECT * FROM old_preKeys;
INSERT INTO signedPreKeys SELECT * FROM old_signedPreKeys;
DROP TABLE old_preKeys;
DROP TABLE old_signedPreKeys;
-- Alter sessions
ALTER TABLE sessions
ADD COLUMN ourUuid STRING;
ALTER TABLE sessions
ADD COLUMN uuid STRING;
`
);
const ourUuid = getOurUuid(db);
if (!isValidUuid(ourUuid)) {
logger.error(
'updateToSchemaVersion41: no uuid is available clearing sessions'
);
clearSessionsAndKeys();
db.pragma('user_version = 41');
return;
}
prefixKeys(ourUuid);
updateSenderKeys(ourUuid);
updateSessions(ourUuid);
moveIdentityKeyToMap(ourUuid);
updateIdentityKeys();
db.pragma('user_version = 41');
})();
logger.info('updateToSchemaVersion41: success!');
}

View file

@ -0,0 +1,77 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import { batchMultiVarQuery } from '../util';
import type { ArrayQuery } from '../util';
import type { LoggerType } from '../../types/Logging';
export default function updateToSchemaVersion42(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 42) {
return;
}
db.transaction(() => {
// First, recreate messages table delete trigger with reaction support
db.exec(`
DROP TRIGGER messages_on_delete;
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.rowid;
DELETE FROM sendLogPayloads WHERE id IN (
SELECT payloadId FROM sendLogMessageIds
WHERE messageId = old.id
);
DELETE FROM reactions WHERE rowid IN (
SELECT rowid FROM reactions
WHERE messageId = old.id
);
END;
`);
// Then, delete previously-orphaned reactions
// Note: we use `pluck` here to fetch only the first column of
// returned row.
const messageIdList: Array<string> = db
.prepare('SELECT id FROM messages ORDER BY id ASC;')
.pluck()
.all();
const allReactions: Array<{
rowid: number;
messageId: string;
}> = db.prepare('SELECT rowid, messageId FROM reactions;').all();
const messageIds = new Set(messageIdList);
const reactionsToDelete: Array<number> = [];
allReactions.forEach(reaction => {
if (!messageIds.has(reaction.messageId)) {
reactionsToDelete.push(reaction.rowid);
}
});
function deleteReactions(rowids: Array<number>) {
db.prepare<ArrayQuery>(
`
DELETE FROM reactions
WHERE rowid IN ( ${rowids.map(() => '?').join(', ')} );
`
).run(rowids);
}
if (reactionsToDelete.length > 0) {
logger.info(`Deleting ${reactionsToDelete.length} orphaned reactions`);
batchMultiVarQuery(db, reactionsToDelete, deleteReactions);
}
db.pragma('user_version = 42');
})();
logger.info('updateToSchemaVersion42: success!');
}

View file

@ -0,0 +1,417 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import { omit } from 'lodash';
import type { LoggerType } from '../../types/Logging';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import { isNotNil } from '../../util/isNotNil';
import { assert } from '../../util/assert';
import {
TableIterator,
getCountFromTable,
jsonToObject,
objectToJSON,
} from '../util';
import type { EmptyQuery, Query } from '../util';
import type { MessageType, ConversationType } from '../Interface';
export default function updateToSchemaVersion43(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 43) {
return;
}
type LegacyPendingMemberType = {
addedByUserId?: string;
conversationId: string;
};
type LegacyAdminApprovalType = {
conversationId: string;
};
type LegacyConversationType = {
id: string;
membersV2?: Array<{
conversationId: string;
}>;
pendingMembersV2?: Array<LegacyPendingMemberType>;
pendingAdminApprovalV2?: Array<LegacyAdminApprovalType>;
};
const getConversationUuid = db
.prepare<Query>(
`
SELECT uuid
FROM
conversations
WHERE
id = $conversationId
`
)
.pluck();
const updateConversationStmt = db.prepare(
`
UPDATE conversations SET
json = $json,
members = $members
WHERE id = $id;
`
);
const updateMessageStmt = db.prepare(
`
UPDATE messages SET
json = $json,
sourceUuid = $sourceUuid
WHERE id = $id;
`
);
const upgradeConversation = (convo: ConversationType) => {
const legacy = (convo as unknown) as LegacyConversationType;
let result = convo;
const memberKeys: Array<keyof LegacyConversationType> = [
'membersV2',
'pendingMembersV2',
'pendingAdminApprovalV2',
];
for (const key of memberKeys) {
const oldValue = legacy[key];
if (!Array.isArray(oldValue)) {
continue;
}
let addedByCount = 0;
const newValue = oldValue
.map(member => {
const uuid: UUIDStringType = getConversationUuid.get({
conversationId: member.conversationId,
});
if (!uuid) {
logger.warn(
`updateToSchemaVersion43: ${legacy.id}.${key} UUID not found ` +
`for ${member.conversationId}`
);
return undefined;
}
const updated = {
...omit(member, 'conversationId'),
uuid,
};
// We previously stored our conversation
if (!('addedByUserId' in member) || !member.addedByUserId) {
return updated;
}
const addedByUserId:
| UUIDStringType
| undefined = getConversationUuid.get({
conversationId: member.addedByUserId,
});
if (!addedByUserId) {
return updated;
}
addedByCount += 1;
return {
...updated,
addedByUserId,
};
})
.filter(isNotNil);
result = {
...result,
[key]: newValue,
};
if (oldValue.length !== 0) {
logger.info(
`updateToSchemaVersion43: migrated ${oldValue.length} ${key} ` +
`entries to ${newValue.length} for ${legacy.id}`
);
}
if (addedByCount > 0) {
logger.info(
`updateToSchemaVersion43: migrated ${addedByCount} addedByUserId ` +
`in ${key} for ${legacy.id}`
);
}
}
if (result === convo) {
return;
}
let dbMembers: string | null;
if (result.membersV2) {
dbMembers = result.membersV2.map(item => item.uuid).join(' ');
} else if (result.members) {
dbMembers = result.members.join(' ');
} else {
dbMembers = null;
}
updateConversationStmt.run({
id: result.id,
json: objectToJSON(result),
members: dbMembers,
});
};
type LegacyMessageType = {
id: string;
groupV2Change?: {
from: string;
details: Array<
(
| {
type:
| 'member-add'
| 'member-add-from-invite'
| 'member-add-from-link'
| 'member-add-from-admin-approval'
| 'member-privilege'
| 'member-remove'
| 'pending-add-one'
| 'pending-remove-one'
| 'admin-approval-add-one'
| 'admin-approval-remove-one';
conversationId: string;
}
| {
type: unknown;
conversationId?: undefined;
}
) &
(
| {
type:
| 'member-add-from-invite'
| 'pending-remove-one'
| 'pending-remove-many'
| 'admin-approval-remove-one';
inviter: string;
}
| {
inviter?: undefined;
}
)
>;
};
sourceUuid: string;
invitedGV2Members?: Array<LegacyPendingMemberType>;
};
const upgradeMessage = (message: MessageType): boolean => {
const {
id,
groupV2Change,
sourceUuid,
invitedGV2Members,
} = (message as unknown) as LegacyMessageType;
let result = message;
if (groupV2Change) {
assert(result.groupV2Change, 'Pacify typescript');
const from: UUIDStringType | undefined = getConversationUuid.get({
conversationId: groupV2Change.from,
});
if (from) {
result = {
...result,
groupV2Change: {
...result.groupV2Change,
from,
},
};
} else {
result = {
...result,
groupV2Change: omit(result.groupV2Change, ['from']),
};
}
let changedDetails = false;
const details = groupV2Change.details
.map((legacyDetail, i) => {
const oldDetail = result.groupV2Change?.details[i];
assert(oldDetail, 'Pacify typescript');
let newDetail = oldDetail;
for (const key of ['conversationId' as const, 'inviter' as const]) {
const oldValue = legacyDetail[key];
const newKey = key === 'conversationId' ? 'uuid' : key;
if (oldValue === undefined) {
continue;
}
changedDetails = true;
let newValue: UUIDStringType | null = getConversationUuid.get({
conversationId: oldValue,
});
if (key === 'inviter') {
newValue = newValue ?? UUID.cast(oldValue);
}
if (!newValue) {
logger.warn(
`updateToSchemaVersion43: ${id}.groupV2Change.details.${key} ` +
`UUID not found for ${oldValue}`
);
return undefined;
}
assert(newDetail.type === legacyDetail.type, 'Pacify typescript');
newDetail = {
...omit(newDetail, key),
[newKey]: newValue,
};
}
return newDetail;
})
.filter(isNotNil);
if (changedDetails) {
result = {
...result,
groupV2Change: {
...result.groupV2Change,
details,
},
};
}
}
if (sourceUuid) {
const newValue: UUIDStringType =
getConversationUuid.get({
conversationId: sourceUuid,
}) ?? UUID.cast(sourceUuid);
if (newValue !== sourceUuid) {
result = {
...result,
sourceUuid: newValue,
};
}
}
if (invitedGV2Members) {
const newMembers = invitedGV2Members
.map(({ addedByUserId, conversationId }, i) => {
const uuid: UUIDStringType | null = getConversationUuid.get({
conversationId,
});
const oldMember =
result.invitedGV2Members && result.invitedGV2Members[i];
assert(oldMember !== undefined, 'Pacify typescript');
if (!uuid) {
logger.warn(
`updateToSchemaVersion43: ${id}.invitedGV2Members UUID ` +
`not found for ${conversationId}`
);
return undefined;
}
const newMember = {
...omit(oldMember, ['conversationId']),
uuid,
};
if (!addedByUserId) {
return newMember;
}
const newAddedBy: UUIDStringType | null = getConversationUuid.get({
conversationId: addedByUserId,
});
if (!newAddedBy) {
return newMember;
}
return {
...newMember,
addedByUserId: newAddedBy,
};
})
.filter(isNotNil);
result = {
...result,
invitedGV2Members: newMembers,
};
}
if (result === message) {
return false;
}
updateMessageStmt.run({
id: result.id,
json: JSON.stringify(result),
sourceUuid: result.sourceUuid ?? null,
});
return true;
};
db.transaction(() => {
const allConversations = db
.prepare<EmptyQuery>(
`
SELECT json, profileLastFetchedAt
FROM conversations
ORDER BY id ASC;
`
)
.all()
.map(({ json }) => jsonToObject<ConversationType>(json));
logger.info(
'updateToSchemaVersion43: About to iterate through ' +
`${allConversations.length} conversations`
);
for (const convo of allConversations) {
upgradeConversation(convo);
}
const messageCount = getCountFromTable(db, 'messages');
logger.info(
'updateToSchemaVersion43: About to iterate through ' +
`${messageCount} messages`
);
let updatedCount = 0;
for (const message of new TableIterator<MessageType>(db, 'messages')) {
if (upgradeMessage(message)) {
updatedCount += 1;
}
}
logger.info(`updateToSchemaVersion43: Updated ${updatedCount} messages`);
db.pragma('user_version = 43');
})();
logger.info('updateToSchemaVersion43: success!');
}

1934
ts/sql/migrations/index.ts Normal file

File diff suppressed because it is too large Load diff