Fix legacy call-history messages without a callId

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Jamie Kyle 2023-08-16 17:11:09 -07:00 committed by Jamie Kyle
parent 6f0401b847
commit ef0a3de636
17 changed files with 831 additions and 426 deletions

View file

@ -1,215 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { callIdFromEra } from '@signalapp/ringrtc';
import Long from 'long';
import type { LoggerType } from '../../types/Logging';
import { sql } from '../util';
import { getOurUuid } from './41-uuid-keys';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
DirectCallStatus,
CallDirection,
CallType,
GroupCallStatus,
callHistoryDetailsSchema,
} from '../../types/CallDisposition';
import { CallMode } from '../../types/Calling';
export default function updateToSchemaVersion87(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 87) {
return;
}
db.transaction(() => {
const ourUuid = getOurUuid(db);
const [createTable] = sql`
DROP TABLE IF EXISTS callsHistory;
CREATE TABLE callsHistory (
callId TEXT PRIMARY KEY,
peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId
ringerId TEXT DEFAULT NULL, -- ringer uuid
mode TEXT NOT NULL, -- enum "Direct" | "Group"
type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group"
direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing
-- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted"
-- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted"
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
UNIQUE (callId, peerId) ON CONFLICT FAIL
);
CREATE INDEX callsHistory_order on callsHistory (timestamp DESC);
CREATE INDEX callsHistory_byConversation ON callsHistory (peerId);
-- For 'getCallHistoryGroupData':
-- This index should target the subqueries for 'possible_parent' and 'possible_children'
CREATE INDEX callsHistory_callAndGroupInfo_optimize on callsHistory (
direction,
peerId,
timestamp DESC,
status
);
`;
db.exec(createTable);
const [selectQuery] = sql`
SELECT * FROM messages
WHERE type = 'call-history'
AND callId IS NOT NULL; -- Older messages don't have callId
`;
const rows = db.prepare(selectQuery).all();
const uniqueConstraint = new Set();
for (const row of rows) {
const json = JSON.parse(row.json);
const details = json.callHistoryDetails;
const { conversationId: peerId } = row;
const { callMode } = details;
let callId: string;
let type: CallType;
let direction: CallDirection;
let status: GroupCallStatus | DirectCallStatus;
let timestamp: number;
let ringerId: string | null = null;
if (details.callMode === CallMode.Direct) {
callId = details.callId;
type = details.wasVideoCall ? CallType.Video : CallType.Audio;
direction = details.wasIncoming
? CallDirection.Incoming
: CallDirection.Outgoing;
if (details.acceptedTime != null) {
status = DirectCallStatus.Accepted;
} else {
status = details.wasDeclined
? DirectCallStatus.Declined
: DirectCallStatus.Missed;
}
timestamp = details.endedTime ?? details.acceptedTime ?? null;
} else if (details.callMode === CallMode.Group) {
callId = Long.fromValue(callIdFromEra(details.eraId)).toString();
type = CallType.Group;
direction =
details.creatorUuid === ourUuid
? CallDirection.Outgoing
: CallDirection.Incoming;
status = GroupCallStatus.GenericGroupCall;
timestamp = details.startedTime;
ringerId = details.creatorUuid;
} else {
logger.error(
`updateToSchemaVersion87: unknown callMode: ${details.callMode}`
);
continue;
}
if (callId == null) {
logger.error(
"updateToSchemaVersion87: callId doesn't exist, too old, skipping"
);
continue;
}
const callHistory: CallHistoryDetails = {
callId,
peerId,
ringerId,
mode: callMode,
type,
direction,
status,
timestamp,
};
const result = callHistoryDetailsSchema.safeParse(callHistory);
if (!result.success) {
logger.error(
`updateToSchemaVersion87: invalid callHistoryDetails (error: ${JSON.stringify(
result.error.format()
)}, input: ${JSON.stringify(json)}, output: ${JSON.stringify(
callHistory
)}))`
);
continue;
}
// We need to ensure a call with the same callId and peerId doesn't get
// inserted twice because of the unique constraint on the table.
const uniqueKey = `${callId} -> ${peerId}`;
if (uniqueConstraint.has(uniqueKey)) {
logger.error(
`updateToSchemaVersion87: duplicate callId/peerId pair (${uniqueKey})`
);
continue;
}
uniqueConstraint.add(uniqueKey);
const [insertQuery, insertParams] = sql`
INSERT INTO callsHistory (
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp
) VALUES (
${callHistory.callId},
${callHistory.peerId},
${callHistory.ringerId},
${callHistory.mode},
${callHistory.type},
${callHistory.direction},
${callHistory.status},
${callHistory.timestamp}
)
`;
db.prepare(insertQuery).run(insertParams);
const [updateQuery, updateParams] = sql`
UPDATE messages
SET json = JSON_PATCH(json, ${JSON.stringify({
callHistoryDetails: null, // delete
callId,
})})
WHERE id = ${row.id}
`;
db.prepare(updateQuery).run(updateParams);
}
const [updateMessagesTable] = sql`
DROP INDEX IF EXISTS messages_call;
ALTER TABLE messages
DROP COLUMN callId;
ALTER TABLE messages
ADD COLUMN callId TEXT;
ALTER TABLE messages
DROP COLUMN callMode;
`;
db.exec(updateMessagesTable);
db.pragma('user_version = 87');
})();
logger.info('updateToSchemaVersion87: success!');
}

View file

@ -0,0 +1,369 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { callIdFromEra } from '@signalapp/ringrtc';
import Long from 'long';
import { v4 as generateUuid } from 'uuid';
import type { SetOptional } from 'type-fest';
import type { LoggerType } from '../../types/Logging';
import { jsonToObject, sql } from '../util';
import { getOurUuid } from './41-uuid-keys';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
DirectCallStatus,
CallDirection,
CallType,
GroupCallStatus,
callHistoryDetailsSchema,
} from '../../types/CallDisposition';
import { CallMode } from '../../types/Calling';
import type { MessageType, ConversationType } from '../Interface';
import { strictAssert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import type { ServiceIdString } from '../../types/ServiceId';
import { isAciString } from '../../types/ServiceId';
// Legacy type for calls that never had a call id
type DirectCallHistoryDetailsType = {
callId?: string;
callMode: CallMode.Direct;
wasIncoming: boolean;
wasVideoCall: boolean;
wasDeclined: boolean;
acceptedTime?: number;
endedTime?: number;
};
type GroupCallHistoryDetailsType = {
callMode: CallMode.Group;
creatorUuid: string;
eraId: string;
startedTime: number;
};
export type CallHistoryDetailsType =
| DirectCallHistoryDetailsType
| GroupCallHistoryDetailsType;
export type CallHistoryDetailsFromDiskType =
// old messages weren't set with a callMode
| SetOptional<DirectCallHistoryDetailsType, 'callMode'>
| SetOptional<GroupCallHistoryDetailsType, 'callMode'>;
export type MessageWithCallHistoryDetails = MessageType & {
callHistoryDetails: CallHistoryDetailsFromDiskType;
};
function upcastCallHistoryDetailsFromDiskType(
callDetails: CallHistoryDetailsFromDiskType
): CallHistoryDetailsType {
if (callDetails.callMode === CallMode.Direct) {
return callDetails as DirectCallHistoryDetailsType;
}
if (callDetails.callMode === CallMode.Group) {
return callDetails as GroupCallHistoryDetailsType;
}
// Some very old calls don't have a callMode, so we need to infer it from the
// other fields. This is a best effort attempt to make sure we at least have
// enough data to form the call history entry correctly.
if (
Object.hasOwn(callDetails, 'wasIncoming') &&
Object.hasOwn(callDetails, 'wasVideoCall')
) {
return {
callMode: CallMode.Direct,
...callDetails,
} as DirectCallHistoryDetailsType;
}
if (
Object.hasOwn(callDetails, 'eraId') &&
Object.hasOwn(callDetails, 'startedTime')
) {
return {
callMode: CallMode.Group,
...callDetails,
} as GroupCallHistoryDetailsType;
}
throw new Error('Could not determine call mode');
}
function getPeerIdFromConversation(
conversation: Pick<ConversationType, 'type' | 'serviceId' | 'groupId'>
): string {
if (conversation.type === 'private') {
strictAssert(
isAciString(conversation.serviceId),
'ACI must exist for direct chat'
);
return conversation.serviceId;
}
strictAssert(
conversation.groupId != null,
'groupId must exist for group chat'
);
return conversation.groupId;
}
function convertLegacyCallDetails(
ourUuid: string | undefined,
peerId: string,
message: MessageType,
partialDetails: CallHistoryDetailsFromDiskType
): CallHistoryDetails {
const details = upcastCallHistoryDetailsFromDiskType(partialDetails);
const { callMode: mode } = details;
let callId: string;
let type: CallType;
let direction: CallDirection;
let status: GroupCallStatus | DirectCallStatus;
let timestamp: number;
let ringerId: string | null = null;
strictAssert(mode != null, 'mode must exist');
if (mode === CallMode.Direct) {
// We don't have a callId for older calls, generating a uuid instead
callId = details.callId ?? generateUuid();
type = details.wasVideoCall ? CallType.Video : CallType.Audio;
direction = details.wasIncoming
? CallDirection.Incoming
: CallDirection.Outgoing;
if (details.acceptedTime != null) {
status = DirectCallStatus.Accepted;
} else {
status = details.wasDeclined
? DirectCallStatus.Declined
: DirectCallStatus.Missed;
}
timestamp = details.acceptedTime ?? details.endedTime ?? message.timestamp;
} else if (mode === CallMode.Group) {
callId = Long.fromValue(callIdFromEra(details.eraId)).toString();
type = CallType.Group;
direction =
details.creatorUuid === ourUuid
? CallDirection.Outgoing
: CallDirection.Incoming;
status = GroupCallStatus.GenericGroupCall;
timestamp = details.startedTime;
ringerId = details.creatorUuid;
} else {
throw missingCaseError(mode);
}
const callHistory: CallHistoryDetails = {
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp,
};
return callHistoryDetailsSchema.parse(callHistory);
}
export default function updateToSchemaVersion89(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 89) {
return;
}
db.transaction(() => {
const ourUuid = getOurUuid(db);
const [createTable] = sql`
-- This table may have already existed from migration 87
CREATE TABLE IF NOT EXISTS callsHistory (
callId TEXT PRIMARY KEY,
peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId
ringerId TEXT DEFAULT NULL, -- ringer uuid
mode TEXT NOT NULL, -- enum "Direct" | "Group"
type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group"
direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing
-- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted"
-- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted"
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
UNIQUE (callId, peerId) ON CONFLICT FAIL
);
-- Update peerId to be uuid or groupId
UPDATE callsHistory
SET peerId = (
SELECT
CASE
WHEN conversations.type = 'private' THEN conversations.serviceId
WHEN conversations.type = 'group' THEN conversations.groupId
END
FROM conversations
WHERE callsHistory.peerId IS conversations.id
AND callsHistory.peerId IS NOT conversations.serviceId
)
WHERE EXISTS (
SELECT 1
FROM conversations
WHERE callsHistory.peerId IS conversations.id
AND callsHistory.peerId IS NOT conversations.serviceId
);
CREATE INDEX IF NOT EXISTS callsHistory_order on callsHistory (timestamp DESC);
CREATE INDEX IF NOT EXISTS callsHistory_byConversation ON callsHistory (peerId);
-- For 'getCallHistoryGroupData':
-- This index should target the subqueries for 'possible_parent' and 'possible_children'
CREATE INDEX IF NOT EXISTS callsHistory_callAndGroupInfo_optimize on callsHistory (
direction,
peerId,
timestamp DESC,
status
);
`;
db.exec(createTable);
const [selectQuery] = sql`
SELECT
messages.json AS messageJson,
conversations.type AS conversationType,
conversations.serviceId AS conversationServiceId,
conversations.groupId AS conversationGroupId
FROM messages
LEFT JOIN conversations ON conversations.id = messages.conversationId
WHERE messages.type = 'call-history'
-- Some of these messages were already migrated
AND messages.json->'callHistoryDetails' IS NOT NULL
-- Sort from oldest to newest, so that newer messages can overwrite older
ORDER BY messages.received_at ASC, messages.sent_at ASC;
`;
// Must match query above
type CallHistoryRow = {
messageJson: string;
conversationType: ConversationType['type'];
conversationServiceId: ServiceIdString | undefined;
conversationGroupId: string | undefined;
};
const rows: Array<CallHistoryRow> = db.prepare(selectQuery).all();
for (const row of rows) {
const {
messageJson,
conversationType,
conversationServiceId,
conversationGroupId,
} = row;
const message = jsonToObject<MessageWithCallHistoryDetails>(messageJson);
const details = message.callHistoryDetails;
const peerId = getPeerIdFromConversation({
type: conversationType,
serviceId: conversationServiceId,
groupId: conversationGroupId,
});
const callHistory = convertLegacyCallDetails(
ourUuid,
peerId,
message,
details
);
const [insertQuery, insertParams] = sql`
-- Using 'OR REPLACE' because in some earlier versions of call history
-- we had a bug where we would insert duplicate call history entries
-- for the same callId and peerId.
-- We're assuming here that the latest call history entry is the most
-- accurate.
INSERT OR REPLACE INTO callsHistory (
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp
) VALUES (
${callHistory.callId},
${callHistory.peerId},
${callHistory.ringerId},
${callHistory.mode},
${callHistory.type},
${callHistory.direction},
${callHistory.status},
${callHistory.timestamp}
)
`;
db.prepare(insertQuery).run(insertParams);
const messageId = message.id;
strictAssert(messageId != null, 'message.id must exist');
const [updateQuery, updateParams] = sql`
UPDATE messages
SET json = JSON_PATCH(json, ${JSON.stringify({
callHistoryDetails: null, // delete
callId: callHistory.callId,
})})
WHERE id = ${messageId}
`;
db.prepare(updateQuery).run(updateParams);
}
const [dropIndex] = sql`
DROP INDEX IF EXISTS messages_call;
`;
db.exec(dropIndex);
try {
const [dropColumnQuery] = sql`
ALTER TABLE messages
DROP COLUMN callMode;
`;
db.exec(dropColumnQuery);
} catch (error) {
if (!error.message.includes('no such column: "callMode"')) {
throw error;
}
}
try {
const [dropColumnQuery] = sql`
ALTER TABLE messages
DROP COLUMN callId;
`;
db.exec(dropColumnQuery);
} catch (error) {
if (!error.message.includes('no such column: "callId"')) {
throw error;
}
}
const [optimizeMessages] = sql`
ALTER TABLE messages
ADD COLUMN callId TEXT
GENERATED ALWAYS AS (
json_extract(json, '$.callId')
);
-- Optimize getCallHistoryMessageByCallId
CREATE INDEX messages_call ON messages
(conversationId, type, callId);
CREATE INDEX messages_callHistory_readStatus ON messages
(type, readStatus)
WHERE type IS 'call-history';
`;
db.exec(optimizeMessages);
db.pragma('user_version = 89');
})();
logger.info('updateToSchemaVersion89: success!');
}

View file

@ -62,8 +62,8 @@ import updateToSchemaVersion83 from './83-mentions';
import updateToSchemaVersion84 from './84-all-mentions';
import updateToSchemaVersion85 from './85-add-kyber-keys';
import updateToSchemaVersion86 from './86-story-replies-index';
import updateToSchemaVersion87 from './87-calls-history-table';
import updateToSchemaVersion88 from './88-service-ids';
import updateToSchemaVersion89 from './89-call-history';
function updateToSchemaVersion1(
currentVersion: number,
@ -1996,8 +1996,9 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion84,
updateToSchemaVersion85,
updateToSchemaVersion86,
updateToSchemaVersion87,
(_v: number, _i: Database, _l: LoggerType): void => undefined, // version 87 was dropped
updateToSchemaVersion88,
updateToSchemaVersion89,
];
export function updateSchema(db: Database, logger: LoggerType): void {