1272 lines
33 KiB
TypeScript
1272 lines
33 KiB
TypeScript
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { Database } from '@signalapp/better-sqlite3';
|
|
import { omit } from 'lodash';
|
|
|
|
import type { LoggerType } from '../../types/Logging';
|
|
import type {
|
|
ServiceIdString,
|
|
AciString,
|
|
PniString,
|
|
} from '../../types/ServiceId';
|
|
import { normalizeServiceId, normalizePni } from '../../types/ServiceId';
|
|
import { normalizeAci } from '../../util/normalizeAci';
|
|
import type { JSONWithUnknownFields } from '../../types/Util';
|
|
import { isNotNil } from '../../util/isNotNil';
|
|
|
|
//
|
|
// Main migration function that does the following:
|
|
//
|
|
// 1. Drop indexes/triggers/generated columns
|
|
// 2. Alter tables to change column names
|
|
// 3. Re-create indexes/triggers/generated columns
|
|
// 4. Call other functions to migrate column and json values in each
|
|
// affected table.
|
|
//
|
|
|
|
export default function updateToSchemaVersion88(
|
|
currentVersion: number,
|
|
db: Database,
|
|
logger: LoggerType
|
|
): void {
|
|
if (currentVersion >= 88) {
|
|
return;
|
|
}
|
|
|
|
// See updateToSchemaVersion84
|
|
const selectMentionsFromMessages = `
|
|
SELECT messages.id, bodyRanges.value ->> 'mentionAci' as mentionAci,
|
|
bodyRanges.value ->> 'start' as start,
|
|
bodyRanges.value ->> 'length' as length
|
|
FROM messages, json_each(messages.json ->> 'bodyRanges') as bodyRanges
|
|
WHERE bodyRanges.value ->> 'mentionAci' IS NOT NULL
|
|
`;
|
|
|
|
db.transaction(() => {
|
|
// Rename all columns and re-create all indexes first.
|
|
db.exec(`
|
|
--
|
|
-- conversations
|
|
--
|
|
|
|
DROP INDEX conversations_uuid;
|
|
|
|
ALTER TABLE conversations
|
|
RENAME COLUMN uuid TO serviceId;
|
|
|
|
-- See: updateToSchemaVersion20
|
|
CREATE INDEX conversations_serviceId ON conversations(serviceId);
|
|
|
|
--
|
|
-- sessions
|
|
--
|
|
|
|
ALTER TABLE sessions
|
|
RENAME COLUMN ourUuid TO ourServiceId;
|
|
ALTER TABLE sessions
|
|
RENAME COLUMN uuid TO serviceId;
|
|
|
|
--
|
|
-- messages
|
|
--
|
|
|
|
DROP INDEX messages_sourceUuid;
|
|
DROP INDEX messages_preview;
|
|
DROP INDEX messages_preview_without_story;
|
|
DROP INDEX messages_activity;
|
|
|
|
ALTER TABLE messages
|
|
DROP COLUMN isGroupLeaveEventFromOther;
|
|
ALTER TABLE messages
|
|
DROP COLUMN isGroupLeaveEvent;
|
|
|
|
ALTER TABLE messages
|
|
RENAME COLUMN sourceUuid TO sourceServiceId;
|
|
|
|
-- See: updateToSchemaVersion47
|
|
ALTER TABLE messages
|
|
ADD COLUMN isGroupLeaveEvent INTEGER
|
|
GENERATED ALWAYS AS (
|
|
type IS 'group-v2-change' AND
|
|
json_array_length(json_extract(json, '$.groupV2Change.details')) IS 1 AND
|
|
json_extract(json, '$.groupV2Change.details[0].type') IS 'member-remove' AND
|
|
json_extract(json, '$.groupV2Change.from') IS NOT NULL AND
|
|
json_extract(json, '$.groupV2Change.from') IS json_extract(json, '$.groupV2Change.details[0].aci')
|
|
);
|
|
|
|
ALTER TABLE messages
|
|
ADD COLUMN isGroupLeaveEventFromOther INTEGER
|
|
GENERATED ALWAYS AS (
|
|
isGroupLeaveEvent IS 1
|
|
AND
|
|
isChangeCreatedByUs IS 0
|
|
);
|
|
|
|
-- See: updateToSchemaVersion25
|
|
CREATE INDEX messages_sourceServiceId on messages(sourceServiceId);
|
|
|
|
-- See: updateToSchemaVersion81
|
|
CREATE INDEX messages_preview ON messages
|
|
(conversationId, shouldAffectPreview, isGroupLeaveEventFromOther,
|
|
received_at, sent_at);
|
|
CREATE INDEX messages_preview_without_story ON messages
|
|
(conversationId, shouldAffectPreview, isGroupLeaveEventFromOther,
|
|
received_at, sent_at) WHERE storyId IS NULL;
|
|
CREATE INDEX messages_activity ON messages
|
|
(conversationId, shouldAffectActivity, isTimerChangeFromSync,
|
|
isGroupLeaveEventFromOther, received_at, sent_at);
|
|
|
|
--
|
|
-- reactions
|
|
--
|
|
|
|
DROP INDEX reaction_identifier;
|
|
|
|
ALTER TABLE reactions
|
|
RENAME COLUMN targetAuthorUuid TO targetAuthorAci;
|
|
|
|
-- See: updateToSchemaVersion29
|
|
CREATE INDEX reaction_identifier ON reactions (
|
|
emoji,
|
|
targetAuthorAci,
|
|
targetTimestamp
|
|
);
|
|
|
|
--
|
|
-- unprocessed
|
|
--
|
|
|
|
ALTER TABLE unprocessed
|
|
RENAME COLUMN sourceUuid TO sourceServiceId;
|
|
|
|
--
|
|
-- sendLogRecipients
|
|
--
|
|
|
|
DROP INDEX sendLogRecipientsByRecipient;
|
|
|
|
ALTER TABLE sendLogRecipients
|
|
RENAME COLUMN recipientUuid TO recipientServiceId;
|
|
|
|
-- See: updateToSchemaVersion37
|
|
CREATE INDEX sendLogRecipientsByRecipient
|
|
ON sendLogRecipients (recipientServiceId, deviceId);
|
|
|
|
--
|
|
-- storyDistributionMembers
|
|
--
|
|
|
|
ALTER TABLE storyDistributionMembers
|
|
RENAME COLUMN uuid TO serviceId;
|
|
|
|
--
|
|
-- mentions
|
|
--
|
|
|
|
DROP TRIGGER messages_on_update;
|
|
DROP TRIGGER messages_on_insert_insert_mentions;
|
|
DROP TRIGGER messages_on_update_update_mentions;
|
|
DROP INDEX mentions_uuid;
|
|
|
|
ALTER TABLE mentions
|
|
RENAME COLUMN mentionUuid TO mentionAci;
|
|
|
|
-- See: updateToSchemaVersion84
|
|
CREATE INDEX mentions_aci ON mentions (mentionAci);
|
|
|
|
--
|
|
-- preKeys
|
|
--
|
|
|
|
DROP INDEX preKeys_ourUuid;
|
|
DROP INDEX signedPreKeys_ourUuid;
|
|
DROP INDEX kyberPreKeys_ourUuid;
|
|
|
|
ALTER TABLE preKeys
|
|
RENAME COLUMN ourUuid TO ourServiceId;
|
|
ALTER TABLE signedPreKeys
|
|
RENAME COLUMN ourUuid TO ourServiceId;
|
|
ALTER TABLE kyberPreKeys
|
|
RENAME COLUMN ourUuid TO ourServiceId;
|
|
|
|
-- See: updateToSchemaVersion64
|
|
CREATE INDEX preKeys_ourServiceId ON preKeys (ourServiceId);
|
|
CREATE INDEX signedPreKeys_ourServiceId ON signedPreKeys (ourServiceId);
|
|
CREATE INDEX kyberPreKeys_ourServiceId ON kyberPreKeys (ourServiceId);
|
|
`);
|
|
|
|
// Migrate JSON fields
|
|
const { identifierToServiceId } = migrateConversations(db, logger);
|
|
const ourServiceIds = migrateItems(db, logger);
|
|
migrateSessions(db, ourServiceIds, logger);
|
|
migrateMessages(db, logger);
|
|
migratePreKeys(db, 'preKeys', ourServiceIds, logger);
|
|
migratePreKeys(db, 'signedPreKeys', ourServiceIds, logger);
|
|
migratePreKeys(db, 'kyberPreKeys', ourServiceIds, logger);
|
|
migrateJobs(db, identifierToServiceId, logger);
|
|
|
|
// Re-create triggers after updating messages
|
|
db.exec(`
|
|
-- See: updateToSchemaVersion45
|
|
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
|
|
WHEN
|
|
(new.body IS NULL OR old.body IS NOT new.body) AND
|
|
new.isViewOnce IS NOT 1 AND new.storyId IS NULL
|
|
BEGIN
|
|
DELETE FROM messages_fts WHERE rowid = old.rowid;
|
|
INSERT INTO messages_fts
|
|
(rowid, body)
|
|
VALUES
|
|
(new.rowid, new.body);
|
|
END;
|
|
|
|
-- See: updateToSchemaVersion84
|
|
CREATE TRIGGER messages_on_insert_insert_mentions AFTER INSERT ON messages
|
|
BEGIN
|
|
INSERT INTO mentions (messageId, mentionAci, start, length)
|
|
${selectMentionsFromMessages}
|
|
AND messages.id = new.id;
|
|
END;
|
|
|
|
CREATE TRIGGER messages_on_update_update_mentions AFTER UPDATE ON messages
|
|
BEGIN
|
|
DELETE FROM mentions WHERE messageId = new.id;
|
|
INSERT INTO mentions (messageId, mentionAci, start, length)
|
|
${selectMentionsFromMessages}
|
|
AND messages.id = new.id;
|
|
END;
|
|
`);
|
|
|
|
db.pragma('user_version = 88');
|
|
})();
|
|
|
|
logger.info('updateToSchemaVersion88: success!');
|
|
}
|
|
|
|
//
|
|
// migrateConversation does the following:
|
|
//
|
|
// 1. Rename `uuid` to `serviceId`
|
|
// 2. Prefix the value of `pni` with `PNI:` if needed
|
|
// 3. Renames various `uuid` fields to either `serviceId` or `aci`.
|
|
//
|
|
// The result is a conversationId to serviceId map to be consumed in
|
|
// other functions.
|
|
//
|
|
|
|
type LegacyBodyRanges = JSONWithUnknownFields<
|
|
Array<{
|
|
mentionUuid?: string;
|
|
}>
|
|
>;
|
|
|
|
type UpdatedBodyRanges = JSONWithUnknownFields<
|
|
Array<{
|
|
mentionAci: AciString | undefined;
|
|
}>
|
|
>;
|
|
|
|
type MigrateConversationsResultType = Readonly<{
|
|
identifierToServiceId: Map<string, ServiceIdString>;
|
|
}>;
|
|
|
|
type LegacyConversationData = JSONWithUnknownFields<{
|
|
uuid?: string | null;
|
|
pni?: string | null;
|
|
bannedMembersV2?: Array<{
|
|
uuid: string;
|
|
}>;
|
|
lastMessageBodyRanges?: LegacyBodyRanges;
|
|
membersV2?: Array<{
|
|
uuid: string;
|
|
}>;
|
|
pendingAdminApprovalV2?: Array<{
|
|
uuid: string;
|
|
}>;
|
|
pendingMembersV2?: Array<{
|
|
uuid: string;
|
|
}>;
|
|
senderKeyInfo?: {
|
|
memberDevices: Array<{ identifier: string }>;
|
|
};
|
|
}>;
|
|
|
|
type UpdatedConversationData = JSONWithUnknownFields<{
|
|
serviceId: ServiceIdString | undefined;
|
|
pni: PniString | undefined;
|
|
bannedMembersV2:
|
|
| Array<{
|
|
serviceId: ServiceIdString;
|
|
}>
|
|
| undefined;
|
|
lastMessageBodyRanges: UpdatedBodyRanges | undefined;
|
|
membersV2:
|
|
| Array<{
|
|
aci: AciString;
|
|
}>
|
|
| undefined;
|
|
pendingAdminApprovalV2:
|
|
| Array<{
|
|
aci: AciString;
|
|
}>
|
|
| undefined;
|
|
pendingMembersV2:
|
|
| Array<{
|
|
serviceId: ServiceIdString;
|
|
}>
|
|
| undefined;
|
|
senderKeyInfo:
|
|
| {
|
|
memberDevices: Array<{ serviceId: ServiceIdString }>;
|
|
}
|
|
| undefined;
|
|
}>;
|
|
|
|
function migrateConversations(
|
|
db: Database,
|
|
logger: LoggerType
|
|
): MigrateConversationsResultType {
|
|
const convos: Array<{
|
|
id: string;
|
|
e164?: string;
|
|
serviceId?: string;
|
|
json: string;
|
|
}> = db.prepare('SELECT id, e164, serviceId, json FROM conversations').all();
|
|
|
|
const updateStmt = db.prepare(
|
|
'UPDATE conversations SET json = $json WHERE id IS $id'
|
|
);
|
|
|
|
logger.info(
|
|
`updateToSchemaVersion88: updating ${convos.length} conversations`
|
|
);
|
|
|
|
// Build lookup map for senderKeyInfo
|
|
const identifierToServiceId = new Map<string, ServiceIdString>();
|
|
for (const { id, e164, serviceId: rawServiceId } of convos) {
|
|
if (!rawServiceId) {
|
|
continue;
|
|
}
|
|
|
|
const serviceId = normalizeServiceId(
|
|
rawServiceId,
|
|
'legacyConvo.serviceId',
|
|
logger
|
|
);
|
|
identifierToServiceId.set(id, serviceId);
|
|
if (e164) {
|
|
identifierToServiceId.set(e164, serviceId);
|
|
}
|
|
identifierToServiceId.set(serviceId, serviceId);
|
|
}
|
|
|
|
for (const { id, json } of convos) {
|
|
try {
|
|
const legacy: LegacyConversationData = JSON.parse(json);
|
|
|
|
const {
|
|
uuid: serviceId,
|
|
pni,
|
|
bannedMembersV2,
|
|
membersV2,
|
|
pendingAdminApprovalV2,
|
|
pendingMembersV2,
|
|
lastMessageBodyRanges,
|
|
senderKeyInfo,
|
|
...restOfConvo
|
|
} = legacy;
|
|
|
|
const modern: UpdatedConversationData = {
|
|
...restOfConvo,
|
|
serviceId: normalizeServiceId(
|
|
serviceId,
|
|
'legacyConvo.serviceId',
|
|
logger
|
|
),
|
|
pni: prefixPni(pni, 'legacyConvo.pni', logger),
|
|
bannedMembersV2: bannedMembersV2?.map(
|
|
({ uuid: memberServiceId, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
serviceId: normalizeServiceId(
|
|
memberServiceId,
|
|
'legacyConvo.bannedMembersV2',
|
|
logger
|
|
),
|
|
};
|
|
}
|
|
),
|
|
membersV2: membersV2?.map(({ uuid: aci, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
aci: normalizeAci(aci, 'legacyConvo.membersV2', logger),
|
|
};
|
|
}),
|
|
pendingAdminApprovalV2: pendingAdminApprovalV2?.map(
|
|
({ uuid: aci, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
aci: normalizeAci(
|
|
aci,
|
|
'legacyConvo.pendingAdminApprovalV2',
|
|
logger
|
|
),
|
|
};
|
|
}
|
|
),
|
|
pendingMembersV2: pendingMembersV2?.map(
|
|
({ uuid: memberServiceId, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
serviceId: normalizeServiceId(
|
|
memberServiceId,
|
|
'legacyConvo.pendingMembersV2',
|
|
logger
|
|
),
|
|
};
|
|
}
|
|
),
|
|
lastMessageBodyRanges: migrateBodyRanges(
|
|
lastMessageBodyRanges,
|
|
'lastMessageBodyRanges',
|
|
logger
|
|
),
|
|
senderKeyInfo: senderKeyInfo
|
|
? {
|
|
...senderKeyInfo,
|
|
memberDevices: senderKeyInfo.memberDevices
|
|
.map(({ identifier, ...rest }) => {
|
|
const deviceServiceId = identifierToServiceId.get(identifier);
|
|
if (!deviceServiceId) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to resolve identifier ${identifier}`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
return { ...rest, serviceId: deviceServiceId };
|
|
})
|
|
.filter(isNotNil),
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
updateStmt.run({ id, json: JSON.stringify(modern) });
|
|
} catch (error) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to parse convo ${id} json`,
|
|
error
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return { identifierToServiceId };
|
|
}
|
|
|
|
//
|
|
// migrateItems does:
|
|
//
|
|
// 1. Migrate `pni` storage item to a prefixed value
|
|
// 2. Return `aci` and `pni` (and their legacy values) to be used in other
|
|
// migrations.
|
|
|
|
type OurServiceIds = Readonly<{
|
|
legacyAci?: string;
|
|
legacyPni?: string;
|
|
aci?: AciString;
|
|
pni?: PniString;
|
|
}>;
|
|
|
|
function migrateItems(db: Database, logger: LoggerType): OurServiceIds {
|
|
// Get our ACI and PNI
|
|
const uuidIdJson = db
|
|
.prepare(
|
|
`
|
|
SELECT json
|
|
FROM items
|
|
WHERE id IS 'uuid_id'
|
|
`
|
|
)
|
|
.pluck()
|
|
.get();
|
|
const pniJson = db
|
|
.prepare(
|
|
`
|
|
SELECT json
|
|
FROM items
|
|
WHERE id IS 'pni'
|
|
`
|
|
)
|
|
.pluck()
|
|
.get();
|
|
|
|
let legacyAci: string | undefined;
|
|
try {
|
|
[legacyAci] = JSON.parse(uuidIdJson).value.split('.', 2);
|
|
} catch (error) {
|
|
if (uuidIdJson) {
|
|
logger.warn(
|
|
'updateToSchemaVersion88: failed to parse uuid_id item',
|
|
error
|
|
);
|
|
} else {
|
|
logger.info('updateToSchemaVersion88: Our UUID not found');
|
|
}
|
|
}
|
|
|
|
let legacyPni: string | undefined;
|
|
try {
|
|
legacyPni = JSON.parse(pniJson).value;
|
|
} catch (error) {
|
|
if (pniJson) {
|
|
logger.warn('updateToSchemaVersion88: failed to parse pni item', error);
|
|
} else {
|
|
logger.info('updateToSchemaVersion88: Our PNI not found');
|
|
}
|
|
}
|
|
|
|
const aci = normalizeAci(legacyAci, 'uuid_id', logger);
|
|
const pni = prefixPni(legacyPni, 'pni', logger);
|
|
|
|
const maps: Array<{ id: string; json: string }> = db
|
|
.prepare(
|
|
`
|
|
SELECT id, json
|
|
FROM items
|
|
WHERE id IN ('identityKeyMap', 'registrationIdMap');
|
|
`
|
|
)
|
|
.all();
|
|
|
|
const updateStmt = db.prepare(
|
|
'UPDATE items SET json = $json WHERE id IS $id'
|
|
);
|
|
|
|
if (pni) {
|
|
updateStmt.run({
|
|
id: 'pni',
|
|
json: JSON.stringify({ id: 'pni', value: pni }),
|
|
});
|
|
}
|
|
|
|
for (const { id, json } of maps) {
|
|
try {
|
|
const data: { id: string; value: Record<string, unknown> } =
|
|
JSON.parse(json);
|
|
|
|
const aciValue = legacyAci && data.value[legacyAci];
|
|
if (legacyAci && aci && aciValue) {
|
|
delete data.value[legacyAci];
|
|
data.value[aci] = aciValue;
|
|
}
|
|
const pniValue = legacyPni && data.value[legacyPni];
|
|
if (legacyPni && pni && pniValue) {
|
|
delete data.value[legacyPni];
|
|
data.value[pni] = pniValue;
|
|
}
|
|
|
|
updateStmt.run({ id, json: JSON.stringify(data) });
|
|
} catch (error) {
|
|
logger.warn(`updateToSchemaVersion88: failed to parse ${id} item`, error);
|
|
}
|
|
}
|
|
return { aci, pni, legacyAci, legacyPni };
|
|
}
|
|
|
|
//
|
|
// migrateSessions does:
|
|
//
|
|
// 1. Update `ourServiceId` to a normalized ACI or (prefixed) PNI in both
|
|
// json and column
|
|
// 2. Update the `session.id` to use new `ourServiceId`
|
|
// (the schema is `${ourServiceId}:${theirServiceId}.${theirDevice}`
|
|
//
|
|
|
|
function migrateSessions(
|
|
db: Database,
|
|
ourServiceIds: OurServiceIds,
|
|
logger: LoggerType
|
|
): void {
|
|
const sessions: Array<{
|
|
id: string;
|
|
serviceId: string;
|
|
ourServiceId: string;
|
|
json: string;
|
|
}> = db
|
|
.prepare('SELECT id, serviceId, ourServiceId, json FROM sessions')
|
|
.all();
|
|
|
|
const updateStmt = db.prepare(
|
|
`
|
|
UPDATE sessions
|
|
SET id = $newId, serviceId = $newServiceId,
|
|
ourServiceId = $newOurServiceId, json = $newJson
|
|
WHERE id IS $id
|
|
`
|
|
);
|
|
|
|
logger.info(`updateToSchemaVersion88: updating ${sessions.length} sessions`);
|
|
for (const { id, serviceId, ourServiceId, json } of sessions) {
|
|
const match = id.match(/^(.*):(.*)\.(.*)$/);
|
|
if (!match) {
|
|
logger.warn(`updateToSchemaVersion88: invalid session id ${id}`);
|
|
continue;
|
|
}
|
|
let legacyData: JSONWithUnknownFields<Record<string, unknown>>;
|
|
try {
|
|
legacyData = JSON.parse(json);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to parse session ${id}`,
|
|
error
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const [, from, to, device] = match;
|
|
|
|
const newId =
|
|
`${migrateServiceId(from, ourServiceIds, logger)}:` +
|
|
`${migrateServiceId(to, ourServiceIds, logger)}.${device}`;
|
|
const newServiceId = migrateServiceId(serviceId, ourServiceIds, logger);
|
|
const newOurServiceId = migrateServiceId(
|
|
ourServiceId,
|
|
ourServiceIds,
|
|
logger
|
|
);
|
|
if (!newServiceId || !newOurServiceId) {
|
|
logger.warn(
|
|
'updateToSchemaVersion88: failed to normalize session service ids',
|
|
serviceId,
|
|
ourServiceId
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const newData: JSONWithUnknownFields<{
|
|
id: string;
|
|
serviceId: ServiceIdString;
|
|
ourServiceId: ServiceIdString;
|
|
}> = {
|
|
...omit(legacyData, 'uuid', 'ourUuid'),
|
|
id: newId,
|
|
serviceId: newServiceId,
|
|
ourServiceId: newOurServiceId,
|
|
};
|
|
|
|
updateStmt.run({
|
|
id,
|
|
newId,
|
|
newServiceId,
|
|
newOurServiceId,
|
|
newJson: JSON.stringify(newData),
|
|
});
|
|
}
|
|
}
|
|
|
|
//
|
|
// Migrate messages processes messages in page by page and does:
|
|
//
|
|
// 1. Update all json fields from `*uuid` to `*serviceId`/`*aci` depending
|
|
// on context.
|
|
|
|
type LegacyBodyRangesAndQuote = JSONWithUnknownFields<{
|
|
bodyRanges?: LegacyBodyRanges;
|
|
quote?: {
|
|
authorUuid?: string;
|
|
bodyRanges?: LegacyBodyRanges;
|
|
};
|
|
}>;
|
|
|
|
type UpdatedBodyRangesAndQuote = JSONWithUnknownFields<{
|
|
bodyRanges: UpdatedBodyRanges | undefined;
|
|
quote:
|
|
| {
|
|
authorAci: AciString | undefined;
|
|
bodyRanges: UpdatedBodyRanges | undefined;
|
|
}
|
|
| undefined;
|
|
}>;
|
|
|
|
type LegacyMessage = JSONWithUnknownFields<
|
|
LegacyBodyRangesAndQuote & {
|
|
id: string;
|
|
sourceUuid?: string;
|
|
expirationTimerUpdate?: {
|
|
sourceUuid?: string;
|
|
};
|
|
reactions?: Array<LegacyReaction>;
|
|
storyReaction?: LegacyReaction;
|
|
storyReplyContext?: {
|
|
authorUuid?: string;
|
|
};
|
|
editHistory?: Array<LegacyBodyRangesAndQuote>;
|
|
groupV2Change?: {
|
|
details?: Array<LegacyGroupChange>;
|
|
};
|
|
}
|
|
>;
|
|
|
|
type UpdatedMessage = JSONWithUnknownFields<
|
|
UpdatedBodyRangesAndQuote & {
|
|
id: string;
|
|
sourceServiceId: ServiceIdString | undefined;
|
|
expirationTimerUpdate:
|
|
| {
|
|
sourceServiceId: ServiceIdString | undefined;
|
|
}
|
|
| undefined;
|
|
reactions: Array<UpdatedReaction> | undefined;
|
|
storyReaction: UpdatedReaction | undefined;
|
|
storyReplyContext:
|
|
| {
|
|
authorAci: AciString | undefined;
|
|
}
|
|
| undefined;
|
|
editHistory: Array<UpdatedBodyRangesAndQuote> | undefined;
|
|
groupV2Change:
|
|
| {
|
|
details: Array<UpdatedGroupChange> | undefined;
|
|
}
|
|
| undefined;
|
|
}
|
|
>;
|
|
|
|
function migrateMessages(db: Database, logger: LoggerType): void {
|
|
const PAGE_SIZE = 10000;
|
|
const getPage = db.prepare(`
|
|
SELECT rowid, id, json
|
|
FROM messages
|
|
LIMIT $limit
|
|
OFFSET $offset
|
|
`);
|
|
|
|
const updateStmt = db.prepare(`
|
|
UPDATE messages
|
|
SET json = $json
|
|
WHERE rowid = $rowid
|
|
`);
|
|
|
|
logger.info('updateToSchemaVersion88: updating messages');
|
|
|
|
let totalMessages = 0;
|
|
// eslint-disable-next-line no-constant-condition
|
|
for (let offset = 0; true; offset += PAGE_SIZE) {
|
|
const messages: Array<{ id: string; rowid: number; json: string }> =
|
|
getPage.all({
|
|
limit: PAGE_SIZE,
|
|
offset,
|
|
});
|
|
if (messages.length === 0) {
|
|
break;
|
|
}
|
|
|
|
totalMessages += messages.length;
|
|
|
|
for (const { rowid, id, json } of messages) {
|
|
try {
|
|
const legacy: LegacyMessage = JSON.parse(json);
|
|
|
|
const {
|
|
sourceUuid,
|
|
expirationTimerUpdate,
|
|
reactions,
|
|
storyReaction,
|
|
storyReplyContext,
|
|
editHistory,
|
|
groupV2Change,
|
|
...restOfMessage
|
|
} = legacy;
|
|
|
|
const updatedMessage: UpdatedMessage = {
|
|
...restOfMessage,
|
|
...omit(
|
|
migrateBodyRangesAndQuote(legacy, 'message', logger),
|
|
'sourceUuid'
|
|
),
|
|
sourceServiceId: normalizeServiceId(sourceUuid, 'sourceUuid'),
|
|
expirationTimerUpdate: expirationTimerUpdate
|
|
? {
|
|
...omit(expirationTimerUpdate, 'sourceUuid'),
|
|
sourceServiceId: normalizeServiceId(
|
|
expirationTimerUpdate.sourceUuid,
|
|
'expirationTimerUpdate.sourceUuid'
|
|
),
|
|
}
|
|
: undefined,
|
|
reactions: reactions?.map(r => migrateReaction(r)),
|
|
storyReaction: storyReaction
|
|
? migrateReaction(storyReaction)
|
|
: undefined,
|
|
storyReplyContext: storyReplyContext
|
|
? {
|
|
...omit(storyReplyContext, 'authorUuid'),
|
|
authorAci: normalizeAci(
|
|
storyReplyContext.authorUuid,
|
|
'storyReplyContext.authorUuid',
|
|
logger
|
|
),
|
|
}
|
|
: undefined,
|
|
editHistory: editHistory?.map(h =>
|
|
migrateBodyRangesAndQuote(h, 'editHistory', logger)
|
|
),
|
|
groupV2Change: groupV2Change
|
|
? {
|
|
...groupV2Change,
|
|
details: groupV2Change.details?.map(d =>
|
|
migrateGroupChange(d, logger)
|
|
),
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
updateStmt.run({
|
|
rowid,
|
|
json: JSON.stringify(updatedMessage),
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to parse message ${id} json`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`updateToSchemaVersion88: updated ${totalMessages} messages`);
|
|
}
|
|
|
|
// migratePreKeys works similarly to migrateSessions and does:
|
|
//
|
|
// 1. Update `ourServiceId` to ACI or (prefixed) PNI
|
|
// 2. Update `id` to use new `ourServiceId` value
|
|
// (the schema is `${ourServiceId}:${keyId}`)
|
|
//
|
|
|
|
function migratePreKeys(
|
|
db: Database,
|
|
table: string,
|
|
ourServiceIds: OurServiceIds,
|
|
logger: LoggerType
|
|
): void {
|
|
const preKeys = db.prepare(`SELECT id, json FROM ${table}`).all();
|
|
|
|
const updateStmt = db.prepare(`
|
|
UPDATE ${table}
|
|
SET id = $newId, json = $newJson
|
|
WHERE id = $id
|
|
`);
|
|
|
|
logger.info(`updateToSchemaVersion88: updating ${preKeys.length} ${table}`);
|
|
for (const { id, json } of preKeys) {
|
|
const match = id.match(/^(.*):(.*)$/);
|
|
if (!match) {
|
|
logger.warn(`updateToSchemaVersion88: invalid ${table} id ${id}`);
|
|
continue;
|
|
}
|
|
|
|
let legacyData: JSONWithUnknownFields<Record<string, unknown>>;
|
|
try {
|
|
legacyData = JSON.parse(json);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to parse ${table} ${id}`,
|
|
error
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const [, ourUuid, keyId] = match;
|
|
|
|
const ourServiceId = migrateServiceId(ourUuid, ourServiceIds, logger);
|
|
const newId = `${ourServiceId}:${keyId}`;
|
|
|
|
const newData: JSONWithUnknownFields<{
|
|
id: string;
|
|
ourServiceId: ServiceIdString;
|
|
}> = {
|
|
...omit(legacyData, 'ourUuid'),
|
|
id: newId,
|
|
ourServiceId,
|
|
};
|
|
|
|
updateStmt.run({
|
|
id,
|
|
newId,
|
|
newJson: JSON.stringify(newData),
|
|
});
|
|
}
|
|
}
|
|
|
|
//
|
|
// migrateJobs does:
|
|
//
|
|
// 1. Update conversation jobs to use `serviceId` instead of `uuid`
|
|
// 1.1. `DeleteStoryForEveryone`
|
|
// 1.2. `ResendRequest`
|
|
// 1.3. `Receipts`
|
|
// 2. Update `read sync`/`view sync`/`view once open sync` to use service ids
|
|
// 3. Update `single proto` job queue to use `serviceId`
|
|
|
|
type LegacyConversationJob = JSONWithUnknownFields<
|
|
| {
|
|
type: 'DeleteStoryForEveryone';
|
|
updatedStoryRecipients: Array<{
|
|
destinationUuid?: string;
|
|
legacyDestinationUuid?: string;
|
|
destinationAci?: string;
|
|
destinationPni?: string;
|
|
}>;
|
|
}
|
|
| {
|
|
type: 'ResendRequest';
|
|
senderUuid: string;
|
|
}
|
|
| {
|
|
type: 'Receipts';
|
|
receipts: Array<{
|
|
senderUuid?: string;
|
|
}>;
|
|
}
|
|
>;
|
|
|
|
type UpdatedConversationJob = JSONWithUnknownFields<
|
|
| {
|
|
type: 'DeleteStoryForEveryone';
|
|
updatedStoryRecipients: Array<{
|
|
destinationServiceId: ServiceIdString | undefined;
|
|
}>;
|
|
}
|
|
| {
|
|
type: 'ResendRequest';
|
|
senderAci: AciString;
|
|
}
|
|
| {
|
|
type: 'Receipts';
|
|
receipts: Array<{
|
|
senderAci: AciString | undefined;
|
|
}>;
|
|
}
|
|
>;
|
|
|
|
type LegacyReadSyncJob = JSONWithUnknownFields<{
|
|
readSyncs: Array<{
|
|
senderUuid: string;
|
|
}>;
|
|
}>;
|
|
|
|
type UpdatedReadSyncJob = JSONWithUnknownFields<{
|
|
readSyncs: Array<{
|
|
senderAci: AciString;
|
|
}>;
|
|
}>;
|
|
|
|
type LegacyViewSyncJob = JSONWithUnknownFields<{
|
|
viewSyncs: Array<{
|
|
senderUuid: string;
|
|
}>;
|
|
}>;
|
|
|
|
type UpdatedViewSyncJob = JSONWithUnknownFields<{
|
|
viewSyncs: Array<{
|
|
senderAci: AciString;
|
|
}>;
|
|
}>;
|
|
|
|
type LegacyViewOnceSyncJob = JSONWithUnknownFields<{
|
|
viewOnceOpens: Array<{
|
|
senderUuid: string;
|
|
}>;
|
|
}>;
|
|
|
|
type UpdatedViewOnceSyncJob = JSONWithUnknownFields<{
|
|
viewOnceOpens: Array<{
|
|
senderAci: AciString;
|
|
}>;
|
|
}>;
|
|
|
|
type LegacySingleProtoJob = JSONWithUnknownFields<{
|
|
identifier: string;
|
|
}>;
|
|
|
|
type UpdatedSingleProtoJob = JSONWithUnknownFields<{
|
|
serviceId: ServiceIdString;
|
|
}>;
|
|
|
|
function migrateJobs(
|
|
db: Database,
|
|
identifierToServiceId: Map<string, ServiceIdString>,
|
|
logger: LoggerType
|
|
): void {
|
|
const jobs = db.prepare('SELECT id, queueType, data FROM jobs').all();
|
|
const updateStmt = db.prepare('UPDATE jobs SET data = $data WHERE id IS $id');
|
|
|
|
let updatedCount = 0;
|
|
for (const { id, queueType, data } of jobs) {
|
|
try {
|
|
const parsedData: unknown = JSON.parse(data);
|
|
|
|
let updatedData: unknown | undefined;
|
|
if (queueType === 'conversation') {
|
|
const convoJob = parsedData as LegacyConversationJob;
|
|
let updatedJob: UpdatedConversationJob | undefined;
|
|
|
|
if (convoJob.type === 'DeleteStoryForEveryone') {
|
|
updatedJob = {
|
|
...convoJob,
|
|
updatedStoryRecipients: convoJob.updatedStoryRecipients.map(
|
|
({
|
|
destinationUuid,
|
|
legacyDestinationUuid,
|
|
destinationAci,
|
|
destinationPni,
|
|
...rest
|
|
}) => {
|
|
return {
|
|
...rest,
|
|
destinationServiceId: normalizeServiceId(
|
|
destinationUuid ||
|
|
destinationAci ||
|
|
destinationPni ||
|
|
legacyDestinationUuid,
|
|
'DeleteStoryForEveryone',
|
|
logger
|
|
),
|
|
};
|
|
}
|
|
),
|
|
};
|
|
} else if (convoJob.type === 'ResendRequest') {
|
|
updatedJob = {
|
|
...omit(convoJob, 'senderUuid'),
|
|
senderAci: normalizeAci(
|
|
convoJob.senderUuid,
|
|
'ResendRequest',
|
|
logger
|
|
),
|
|
};
|
|
} else if (convoJob.type === 'Receipts') {
|
|
updatedJob = {
|
|
...convoJob,
|
|
receipts: convoJob.receipts.map(({ senderUuid, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
senderAci: normalizeAci(senderUuid, 'Receipts', logger),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
updatedData = updatedJob;
|
|
} else if (queueType === 'read sync') {
|
|
const syncJob = parsedData as LegacyReadSyncJob;
|
|
const updatedJob: UpdatedReadSyncJob = {
|
|
...syncJob,
|
|
readSyncs: syncJob.readSyncs.map(({ senderUuid, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
senderAci: normalizeAci(senderUuid, 'read sync'),
|
|
};
|
|
}),
|
|
};
|
|
|
|
updatedData = updatedJob;
|
|
} else if (queueType === 'view sync') {
|
|
const syncJob = parsedData as LegacyViewSyncJob;
|
|
const updatedJob: UpdatedViewSyncJob = {
|
|
...syncJob,
|
|
viewSyncs: syncJob.viewSyncs.map(({ senderUuid, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
senderAci: normalizeAci(senderUuid, 'read sync'),
|
|
};
|
|
}),
|
|
};
|
|
|
|
updatedData = updatedJob;
|
|
} else if (queueType === 'view once open sync') {
|
|
const syncJob = parsedData as LegacyViewOnceSyncJob;
|
|
const updatedJob: UpdatedViewOnceSyncJob = {
|
|
...syncJob,
|
|
viewOnceOpens: syncJob.viewOnceOpens.map(
|
|
({ senderUuid, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
senderAci: normalizeAci(senderUuid, 'read sync'),
|
|
};
|
|
}
|
|
),
|
|
};
|
|
|
|
updatedData = updatedJob;
|
|
} else if (queueType === 'single proto') {
|
|
const { identifier, ...syncJob } = parsedData as LegacySingleProtoJob;
|
|
const serviceId = identifierToServiceId.get(identifier);
|
|
if (!serviceId) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to resolve identifier ${identifier} ` +
|
|
`for job ${id}/${queueType}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const updatedJob: UpdatedSingleProtoJob = {
|
|
...syncJob,
|
|
serviceId,
|
|
};
|
|
|
|
updatedData = updatedJob;
|
|
}
|
|
|
|
if (updatedData !== undefined) {
|
|
updatedCount += 1;
|
|
updateStmt.run({ id, data: JSON.stringify(updatedData) });
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`updateToSchemaVersion88: failed to migrate job ${id}/${queueType} json`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
logger.info(`updateToSchemaVersion88: updated ${updatedCount} jobs`);
|
|
}
|
|
|
|
//
|
|
// Various utility methods below.
|
|
//
|
|
|
|
function migrateBodyRangesAndQuote(
|
|
{ bodyRanges, quote, ...rest }: LegacyBodyRangesAndQuote,
|
|
context: string,
|
|
logger: LoggerType
|
|
): UpdatedBodyRangesAndQuote {
|
|
return {
|
|
...rest,
|
|
bodyRanges: bodyRanges
|
|
? migrateBodyRanges(bodyRanges, `${context}.bodyRanges`, logger)
|
|
: undefined,
|
|
quote: quote
|
|
? {
|
|
...quote,
|
|
authorAci: normalizeAci(
|
|
quote.authorUuid,
|
|
`${context}.quote.authorUuid`,
|
|
logger
|
|
),
|
|
bodyRanges: quote.bodyRanges
|
|
? migrateBodyRanges(
|
|
quote.bodyRanges,
|
|
`${context}.quote.bodyRanges`,
|
|
logger
|
|
)
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function migrateServiceId(
|
|
legacyId: string,
|
|
ourServiceIds: OurServiceIds,
|
|
logger: LoggerType
|
|
): ServiceIdString;
|
|
|
|
function migrateServiceId(
|
|
legacyId: string | null | undefined,
|
|
{ legacyAci, legacyPni, aci, pni }: OurServiceIds,
|
|
logger: LoggerType
|
|
): ServiceIdString | undefined {
|
|
if (legacyId == null) {
|
|
return undefined;
|
|
}
|
|
if (legacyId === legacyAci) {
|
|
return aci;
|
|
}
|
|
if (legacyId === legacyPni) {
|
|
return pni;
|
|
}
|
|
return normalizeServiceId(legacyId, `migrateServiceId(${legacyId})`, logger);
|
|
}
|
|
|
|
function prefixPni(
|
|
legacyPni: string | null | undefined,
|
|
context: string,
|
|
logger: LoggerType
|
|
): PniString | undefined {
|
|
if (legacyPni == null) {
|
|
return undefined;
|
|
}
|
|
|
|
if (legacyPni.toLowerCase().startsWith('pni:')) {
|
|
return normalizePni(legacyPni, context, logger);
|
|
}
|
|
|
|
return normalizePni(`PNI:${legacyPni}`, context, logger);
|
|
}
|
|
|
|
function migrateBodyRanges(
|
|
legacy: LegacyBodyRanges | undefined | null,
|
|
context: string,
|
|
logger: LoggerType
|
|
): UpdatedBodyRanges | undefined {
|
|
if (legacy == null) {
|
|
return undefined;
|
|
}
|
|
return legacy?.map(({ mentionUuid: mentionAci, ...rest }) => {
|
|
return {
|
|
...rest,
|
|
mentionAci: normalizeAci(mentionAci, context, logger),
|
|
};
|
|
});
|
|
}
|
|
|
|
type LegacyReaction = JSONWithUnknownFields<{
|
|
targetAuthorUuid?: string;
|
|
}>;
|
|
|
|
type UpdatedReaction = JSONWithUnknownFields<Record<string, unknown>>;
|
|
|
|
function migrateReaction(legacy: LegacyReaction): UpdatedReaction {
|
|
return omit(legacy, 'targetAuthorUuid');
|
|
}
|
|
|
|
type LegacyGroupChange = JSONWithUnknownFields<{
|
|
type: string;
|
|
uuid?: string;
|
|
}>;
|
|
|
|
type UpdatedGroupChange = JSONWithUnknownFields<{
|
|
type: string;
|
|
serviceId: ServiceIdString | undefined;
|
|
aci: AciString | undefined;
|
|
}>;
|
|
|
|
const GROUP_CHANGES_WITH_SERVICE_ID = new Set([
|
|
'pending-add-one',
|
|
'pending-remove-one',
|
|
]);
|
|
|
|
function migrateGroupChange(
|
|
{ type, uuid, ...rest }: LegacyGroupChange,
|
|
logger: LoggerType
|
|
): UpdatedGroupChange {
|
|
let aci: AciString | undefined;
|
|
let serviceId: ServiceIdString | undefined;
|
|
if (GROUP_CHANGES_WITH_SERVICE_ID.has(type)) {
|
|
serviceId = normalizeServiceId(uuid, `migrateGroupChange(${type})`, logger);
|
|
} else {
|
|
aci = normalizeAci(uuid, `migrateGroupChange(${type})`, logger);
|
|
}
|
|
|
|
return {
|
|
...rest,
|
|
type,
|
|
aci,
|
|
serviceId,
|
|
};
|
|
}
|