Storage Service: A few correctness fixes

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
automated-signal
2026-09-02 19:53:58 +00:00
committed by GitHub
co-authored by Scott Nonnenberg
parent ae0fecd185
commit 35bad4c170
29 changed files with 1355 additions and 537 deletions
+20 -1
View File
@@ -348,6 +348,15 @@ CREATE INDEX callLinks_deleted ON callLinks (deleted, roomId)
</details>
<details>
<summary>Index: callLinks → callLinks_expiring</summary>
```sql
CREATE INDEX callLinks_expiring ON callLinks (deletedAt)
```
</details>
<details>
<summary>Index: callLinks → sqlite_autoindex_callLinks_1</summary>
@@ -601,10 +610,20 @@ CREATE TABLE defunctCallLinks (
storageID TEXT,
storageVersion INTEGER,
storageUnknownFields BLOB,
storageNeedsSync INTEGER NOT NULL DEFAULT 0
storageNeedsSync INTEGER NOT NULL DEFAULT 0,
addedAt INTEGER NOT NULL
) STRICT
```
<details>
<summary>Index: defunctCallLinks → defunctCallLinks_expiring</summary>
```sql
CREATE INDEX defunctCallLinks_expiring ON defunctCallLinks (addedAt)
```
</details>
<details>
<summary>Index: defunctCallLinks → sqlite_autoindex_defunctCallLinks_1</summary>
+4 -4
View File
@@ -7393,8 +7393,8 @@ packages:
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==, tarball: https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz}
fast-uri@3.1.6:
resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==, tarball: https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz}
@@ -16838,7 +16838,7 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
fast-uri: 3.1.6
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -18825,7 +18825,7 @@ snapshots:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.5: {}
fast-uri@3.1.6: {}
fast-wrap-ansi@0.2.2:
dependencies:
@@ -1,206 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as durations from '../util/durations/index.std.ts';
import { createLogger } from '../logging/log.std.ts';
import { DataReader, DataWriter } from '../sql/Client.preload.ts';
import {
JobManager,
type JobManagerParamsType,
type JobManagerJobResultType,
type JobManagerJobType,
} from './JobManager.std.ts';
const log = createLogger('CallLinkFinalizeDeleteManager');
export type CoreCallLinkDeleteJobType = {
roomId: string;
};
export type CallLinkDeleteJobType = CoreCallLinkDeleteJobType &
JobManagerJobType;
const MAX_CONCURRENT_JOBS = 5;
const DEFAULT_RETRY_CONFIG = {
maxAttempts: 10,
backoffConfig: {
// 1 min, 5 min, 25 min, (max) 1 day
multiplier: 5,
firstBackoffs: [durations.MINUTE],
maxBackoffTime: durations.DAY,
},
};
type CallLinkFinalizeDeleteManagerParamsType =
JobManagerParamsType<CoreCallLinkDeleteJobType>;
function getJobId(job: CoreCallLinkDeleteJobType): string {
return job.roomId;
}
// The purpose of this job is to finalize local DB delete of call links and
// associated call history, after we confirm storage sync.
// It does *not* delete the call link from the server -- this should be done
// synchronously and prior to running this job, so we can show confirmation
// or error to the user.
export class CallLinkFinalizeDeleteManager extends JobManager<CoreCallLinkDeleteJobType> {
jobs = new Map<string, CallLinkDeleteJobType>();
private static _instance: CallLinkFinalizeDeleteManager | undefined;
override logPrefix = 'CallLinkFinalizeDeleteManager';
static defaultParams: CallLinkFinalizeDeleteManagerParamsType = {
markAllJobsInactive: () => Promise.resolve(),
getNextJobs,
saveJob,
removeJob,
runJob,
getJobId,
getJobIdForLogging: getJobId,
getRetryConfig: () => DEFAULT_RETRY_CONFIG,
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
};
constructor(params: CallLinkFinalizeDeleteManagerParamsType) {
super({
...params,
getNextJobs: ({ limit, timestamp }) =>
params.getNextJobs.call(this, { limit, timestamp }),
saveJob: (job: CallLinkDeleteJobType) => params.saveJob.call(this, job),
removeJob: (job: CallLinkDeleteJobType) =>
params.removeJob.call(this, job),
});
}
override async addJob(
jobData: CoreCallLinkDeleteJobType,
options?: { delay: number }
): Promise<void> {
const { delay } = options || {};
if (delay) {
log.info(
`CallLinkDeleteJobType/addJob/${getJobId(jobData)}: Adding with delay ${delay}`
);
const job: CallLinkDeleteJobType = {
...jobData,
attempts: 0,
retryAfter: Date.now() + delay,
lastAttemptTimestamp: null,
active: false,
};
await this.params.saveJob(job);
return;
}
await this._addJob(jobData);
}
async enqueueAllDeletedCallLinks(options?: { delay: number }): Promise<void> {
const roomIds = await DataReader.getAllMarkedDeletedCallLinkRoomIds();
log.info(
`CallLinkDeleteJobType/enqueueAllDeletedCallLinks: Found ${roomIds.length} call links to delete`
);
roomIds.forEach(roomId => this.addJob({ roomId }, options));
}
static get instance(): CallLinkFinalizeDeleteManager {
if (!CallLinkFinalizeDeleteManager._instance) {
CallLinkFinalizeDeleteManager._instance =
new CallLinkFinalizeDeleteManager(
CallLinkFinalizeDeleteManager.defaultParams
);
}
return CallLinkFinalizeDeleteManager._instance;
}
static async start(): Promise<void> {
await CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks();
await CallLinkFinalizeDeleteManager.instance.start();
}
static async stop(): Promise<void> {
return CallLinkFinalizeDeleteManager._instance?.stop();
}
static async addJob(
newJob: CoreCallLinkDeleteJobType,
options?: { delay: number }
): Promise<void> {
return CallLinkFinalizeDeleteManager.instance.addJob(newJob, options);
}
static async enqueueAllDeletedCallLinks(options?: {
delay: number;
}): Promise<void> {
return CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks(
options
);
}
}
async function getNextJobs(
this: CallLinkFinalizeDeleteManager,
{
limit,
timestamp,
}: {
limit: number;
timestamp: number;
}
): Promise<Array<CallLinkDeleteJobType>> {
let countRemaining = limit;
const nextJobs: Array<CallLinkDeleteJobType> = [];
for (const job of this.jobs.values()) {
if (job.active || (job.retryAfter && job.retryAfter > timestamp)) {
continue;
}
nextJobs.push(job);
countRemaining -= 1;
if (countRemaining <= 0) {
break;
}
}
return nextJobs;
}
async function saveJob(
this: CallLinkFinalizeDeleteManager,
job: CallLinkDeleteJobType
): Promise<void> {
const { roomId } = job;
this.jobs.set(roomId, job);
}
async function removeJob(
this: CallLinkFinalizeDeleteManager,
job: CallLinkDeleteJobType
): Promise<void> {
this.jobs.delete(job.roomId);
}
async function runJob(
job: CallLinkDeleteJobType,
_options: { isLastAttempt: boolean; abortSignal: AbortSignal }
): Promise<JobManagerJobResultType<CoreCallLinkDeleteJobType>> {
const logId = `CallLinkDeleteJobType/runJob/${getJobId(job)}`;
const callLinkRecord = await DataReader.getCallLinkRecordByRoomId(job.roomId);
if (callLinkRecord == null) {
log.warn(`${logId}: Call link gone from DB`);
return { status: 'finished' };
}
if (callLinkRecord.deleted !== 1) {
log.error(`${logId}: Call link not marked deleted. Giving up.`);
return { status: 'finished' };
}
// For consistency between devices, wait for storage sync
if (callLinkRecord.storageNeedsSync !== 0) {
log.info(`${logId}: Call link storage needs sync; retrying later`);
return { status: 'retry' };
}
await DataWriter.finalizeDeleteCallLink(job.roomId);
log.info(`${logId}: Finalized local delete`);
return { status: 'finished' };
}
+11 -3
View File
@@ -24,6 +24,8 @@ import { parseUnknown } from '../util/schemas.std.ts';
import { getRoomIdFromRootKey } from '../util/callLinksRingrtc.node.ts';
import { toCallHistoryFromUnusedCallLink } from '../util/callLinks.std.ts';
import type { StorageServiceFieldsType } from '../sql/Interface.std.ts';
import { defunctCallLinkCleanupService } from '../services/expiring/defunctCallLinkCleanupService.preload.ts';
import { drop } from '../util/drop.std.ts';
const globalLogger = createLogger('callLinkRefreshJobQueue');
@@ -137,10 +139,10 @@ class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
}
protected getPendingCallLinkStorageFields(
storageID: string,
rootKey: string,
jobData: CallLinkRefreshJobData
): StorageServiceFieldsType | undefined {
const storageFields = this.#pendingCallLinks.get(storageID);
const storageFields = this.#pendingCallLinks.get(rootKey);
if (storageFields) {
return {
storageID: storageFields.storageID,
@@ -240,9 +242,15 @@ class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
roomId,
rootKey,
adminKey: data.adminKey ?? null,
addedAt: Date.now(),
...storageFields,
storageNeedsSync: false,
storageNeedsSync: true,
});
drop(
defunctCallLinkCleanupService.trigger(
'callLinkRefreshJobQueue: added defunct call link'
)
);
} else {
log.info(
`${logId}: Call link not found on server but present locally, ignoring`
+14 -11
View File
@@ -3,20 +3,21 @@
import type { reportMessage, isOnline } from '../textsecure/WebAPI.preload.ts';
import { drop } from '../util/drop.std.ts';
import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager.preload.ts';
import { chatFolderCleanupService } from '../services/expiring/chatFolderCleanupService.preload.ts';
import { pinnedMessagesCleanupService } from '../services/expiring/pinnedMessagesCleanupService.preload.ts';
import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue.preload.ts';
import { conversationJobQueue } from './conversationJobQueue.preload.ts';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue.preload.ts';
import { groupAvatarJobQueue } from './groupAvatarJobQueue.preload.ts';
import { singleProtoJobQueue } from './singleProtoJobQueue.preload.ts';
import { readSyncJobQueue } from './readSyncJobQueue.preload.ts';
import { viewSyncJobQueue } from './viewSyncJobQueue.preload.ts';
import { viewOnceOpenJobQueue } from './viewOnceOpenJobQueue.preload.ts';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue.preload.ts';
import { registrationJobQueue } from './registrationJobQueue.preload.ts';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue.preload.ts';
import { reportSpamJobQueue } from './reportSpamJobQueue.preload.ts';
import { singleProtoJobQueue } from './singleProtoJobQueue.preload.ts';
import { viewOnceOpenJobQueue } from './viewOnceOpenJobQueue.preload.ts';
import { viewSyncJobQueue } from './viewSyncJobQueue.preload.ts';
import { registrationJobQueue } from './registrationJobQueue.preload.ts';
import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue.preload.ts';
import { callLinkCleanupService } from '../services/expiring/callLinkCleanupService.preload.ts';
import { defunctCallLinkCleanupService } from '../services/expiring/defunctCallLinkCleanupService.preload.ts';
import { chatFolderCleanupService } from '../services/expiring/chatFolderCleanupService.preload.ts';
import { pinnedMessagesCleanupService } from '../services/expiring/pinnedMessagesCleanupService.preload.ts';
type ServerType = {
reportMessage: typeof reportMessage;
@@ -53,7 +54,8 @@ export function initializeAllJobQueues({
drop(removeStorageKeyJobQueue.streamJobs());
drop(reportSpamJobQueue.streamJobs());
drop(callLinkRefreshJobQueue.streamJobs());
drop(CallLinkFinalizeDeleteManager.start());
drop(callLinkCleanupService.start('initializeAllJobQueues'));
drop(defunctCallLinkCleanupService.start('initializeAllJobQueues'));
drop(chatFolderCleanupService.start('initializeAllJobQueues'));
drop(pinnedMessagesCleanupService.start('initializeAllJobQueues'));
}
@@ -71,7 +73,8 @@ export async function shutdownAllJobQueues(): Promise<void> {
removeStorageKeyJobQueue.shutdown(),
reportSpamJobQueue.shutdown(),
callLinkRefreshJobQueue.shutdown(),
CallLinkFinalizeDeleteManager.stop(),
callLinkCleanupService.stop('shutdownAllJobQueues'),
defunctCallLinkCleanupService.stop('shutdownAllJobQueues'),
chatFolderCleanupService.stop('shutdownAllJobQueues'),
pinnedMessagesCleanupService.stop('shutdownAllJobQueues'),
]);
+16
View File
@@ -760,6 +760,10 @@ export class BackupExportStream extends Readable {
const allNotificationProfiles =
await DataReader.getAllNotificationProfiles();
const isNotificationProfileSyncDisabled = itemStorage.get(
'notificationProfileSyncDisabled',
false
);
for (const profile of allNotificationProfiles) {
const {
@@ -768,6 +772,7 @@ export class BackupExportStream extends Readable {
emoji = null,
color,
createdAtMs,
deletedAtTimestampMs,
allowAllCalls,
allowAllMentions,
allowedMembers,
@@ -775,8 +780,19 @@ export class BackupExportStream extends Readable {
scheduleStartTime = null,
scheduleEndTime = null,
scheduleDaysEnabled,
storageID,
} = profile;
// Skipping deleted profile
if (isNumber(deletedAtTimestampMs) && deletedAtTimestampMs > 0) {
continue;
}
// sync=OFF, and so only exporting profiles with storageID (from Primary)
if (isNotificationProfileSyncDisabled && !storageID) {
continue;
}
const allowedRecipients = Array.from(allowedMembers)
.map(conversationId =>
this.#getRecipientByConversationId(
@@ -0,0 +1,36 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DataReader, DataWriter } from '../../sql/Client.preload.ts';
import { getMessageQueueTime } from '../../util/getMessageQueueTime.dom.ts';
import { createExpiringEntityCleanupService } from './createExpiringEntityCleanupService.std.ts';
import * as RemoteConfig from '../../RemoteConfig.dom.ts';
export const callLinkCleanupService = createExpiringEntityCleanupService({
logPrefix: 'CallLink',
getNextExpiringEntity: async () => {
const item = await DataReader.getTimestampOfOldestDeletedCallLink();
if (!item) {
return null;
}
const messageQueueTime = getMessageQueueTime();
const expiresAtMs = item.deletedAt + messageQueueTime;
return { id: item.roomId, expiresAtMs };
},
cleanupExpiredEntities: async () => {
const messageQueueTime = getMessageQueueTime();
const ids = await DataWriter.deleteExpiredCallLinks(messageQueueTime);
return ids;
},
subscribeToTriggers: trigger => {
let prevMessageQueueTime = getMessageQueueTime();
return RemoteConfig.onChange(['global.messageQueueTimeInSeconds'], () => {
const messageQueueTime = getMessageQueueTime();
if (messageQueueTime !== prevMessageQueueTime) {
trigger('messageQueueTime changed');
}
prevMessageQueueTime = getMessageQueueTime();
});
},
});
@@ -0,0 +1,39 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DataReader, DataWriter } from '../../sql/Client.preload.ts';
import { getMessageQueueTime } from '../../util/getMessageQueueTime.dom.ts';
import { createExpiringEntityCleanupService } from './createExpiringEntityCleanupService.std.ts';
import * as RemoteConfig from '../../RemoteConfig.dom.ts';
export const defunctCallLinkCleanupService = createExpiringEntityCleanupService(
{
logPrefix: 'DefunctCallLink',
getNextExpiringEntity: async () => {
const item = await DataReader.getTimestampOfOldestDefunctCallLink();
if (!item) {
return null;
}
const messageQueueTime = getMessageQueueTime();
const expiresAtMs = item.addedAt + messageQueueTime;
return { id: item.roomId, expiresAtMs };
},
cleanupExpiredEntities: async () => {
const messageQueueTime = getMessageQueueTime();
const ids =
await DataWriter.deleteExpiredDefunctCallLinks(messageQueueTime);
return ids;
},
subscribeToTriggers: trigger => {
let prevMessageQueueTime = getMessageQueueTime();
return RemoteConfig.onChange(['global.messageQueueTimeInSeconds'], () => {
const messageQueueTime = getMessageQueueTime();
if (messageQueueTime !== prevMessageQueueTime) {
trigger('messageQueueTime changed');
}
prevMessageQueueTime = getMessageQueueTime();
});
},
}
);
+147 -116
View File
@@ -31,8 +31,9 @@ import {
toStickerPackRecord,
toCallLinkRecord,
mergeCallLinkRecord,
toDefunctOrPendingCallLinkRecord,
toChatFolderRecord,
toDefunctCallLinkRecord,
toPendingCallLinkRecord,
mergeChatFolderRecord,
mergeNotificationProfileRecord,
} from './storageRecordOps.preload.ts';
@@ -201,7 +202,8 @@ async function generateManifest(
previousManifest?: Proto.ManifestRecord,
isNewManifest = false
): Promise<GeneratedManifestType> {
log.info(`upload(${version}): generating manifest new=${isNewManifest}`);
const logId = `generateManifest(${version})`;
log.info(`${logId}: generating manifest new=${isNewManifest}`);
await window.ConversationController.checkForConflicts();
@@ -254,8 +256,7 @@ async function generateManifest(
const newRedactedID = redactStorageID(storageID, version, conversation);
if (currentStorageID) {
log.info(
`upload(${version}): ` +
`updating from=${currentRedactedID} ` +
`${logId}: updating from=${currentRedactedID} ` +
`to=${newRedactedID}`
);
deleteKeys.add(currentStorageID);
@@ -364,11 +365,7 @@ async function generateManifest(
conversation
);
log.warn(
`generateManifest(${version}): ` +
`dropping contact=${recordID} ` +
`due to ${dropReason}`
);
log.warn(`${logId}: dropping contact=${recordID} due to ${dropReason}`);
conversation.set({ storageID: undefined });
deleteKeys.add(droppedID);
continue;
@@ -386,8 +383,7 @@ async function generateManifest(
// first before syncing it to storage service.
if (conversation.get('needsGroupUpdate') === true) {
log.warn(
`upload(${version}): ` +
`dropping group=${conversation.idForLogging()} until it is updated`
`${logId}: dropping group=${conversation.idForLogging()} until it is updated`
);
continue;
}
@@ -406,10 +402,7 @@ async function generateManifest(
};
identifierType = ITEM_TYPE.GROUPV1;
} else {
log.warn(
`upload(${version}): ` +
`unknown conversation=${conversation.idForLogging()}`
);
log.warn(`${logId}: unknown conversation=${conversation.idForLogging()}`);
}
if (!storageRecord || !identifierType) {
@@ -449,8 +442,7 @@ async function generateManifest(
} = await getNonConversationRecords();
log.info(
`upload(${version}): ` +
`adding storyDistributionLists=${storyDistributionLists.length}`
`${logId}: adding storyDistributionLists=${storyDistributionLists.length}`
);
for (const storyDistributionList of storyDistributionLists) {
@@ -478,8 +470,7 @@ async function generateManifest(
const recordID = redactStorageID(droppedID, droppedVersion);
log.warn(
`generateManifest(${version}): ` +
`dropping storyDistributionList=${recordID} ` +
`${logId}: dropping storyDistributionList=${recordID} ` +
`due to expired deleted timestamp=${storyDistributionList.deletedAtTimestamp}`
);
deleteKeys.add(droppedID);
@@ -515,13 +506,11 @@ async function generateManifest(
const localOnlyCount =
notificationProfiles.length - notificationProfilesToUpload.length;
log.info(
`upload(${version}): ` +
`sync=OFF; adding notificationProfiles=${notificationProfilesToUpload.length}, excluding ${localOnlyCount} local profiles`
`${logId}: sync=OFF; adding notificationProfiles=${notificationProfilesToUpload.length}, excluding ${localOnlyCount} local profiles`
);
} else {
log.info(
`upload(${version}): ` +
`sync=ON, adding notificationProfiles=${notificationProfilesToUpload.length}`
`${logId}: sync=ON, adding notificationProfiles=${notificationProfilesToUpload.length}`
);
}
for (const notificationProfile of notificationProfilesToUpload) {
@@ -548,8 +537,7 @@ async function generateManifest(
const recordID = redactStorageID(droppedID, droppedVersion);
log.info(
`generateManifest(${version}): ` +
`dropping notificationProfile=${recordID} ` +
`${logId}: dropping notificationProfile=${recordID} ` +
`due to expired deleted timestamp=${notificationProfile.deletedAtTimestampMs}`
);
deleteKeys.add(droppedID);
@@ -604,15 +592,13 @@ async function generateManifest(
const recordID = redactStorageID(droppedID, droppedVersion);
log.info(
`generateManifest(${version}): ` +
`dropping stickerPack=${recordID} ` +
`${logId}: dropping stickerPack=${recordID} ` +
`due to expired deleted timestamp=${stickerPack.uninstalledAt}`
);
deleteKeys.add(droppedID);
} else {
log.info(
`generateManifest(${version}): ` +
`dropping never uploaded stickerPack=${stickerPack.id}` +
`${logId}: dropping never uploaded stickerPack=${stickerPack.id}` +
`due to expired deleted timestamp=${stickerPack.uninstalledAt}`
);
}
@@ -689,7 +675,7 @@ async function generateManifest(
});
log.info(
`upload(${version}): stickerPacks ` +
`${logId}: stickerPacks ` +
`installed=${newlyInstalledPacks}/${installedStickerPacks.length} ` +
`uninstalled=${newlyUninstalledPacks}/${uninstalledStickerPacks.length}`
);
@@ -701,7 +687,7 @@ async function generateManifest(
for (const callLinkDbRecord of callLinkDbRecords) {
const { roomId } = callLinkDbRecord;
if (callLinkDbRecord.adminKey == null || callLinkDbRecord.rootKey == null) {
log.warn(`upload(${version}): call link ${roomId} has empty rootKey`);
log.warn(`${logId}: call link ${roomId} has empty rootKey`);
continue;
}
@@ -733,8 +719,7 @@ async function generateManifest(
const freshCallLink = await DataReader.getCallLinkByRoomId(roomId);
if (freshCallLink == null) {
log.warn(
`upload(${version}): ` +
`call link ${roomId} removed locally from DB while we were uploading to storage`
`${logId}: call link ${roomId} removed locally from DB while we were uploading to storage`
);
return;
}
@@ -746,15 +731,12 @@ async function generateManifest(
}
}
log.info(
`upload(${version}): ` +
`adding defunctCallLinks=${defunctCallLinks.length}`
);
log.info(`${logId}: adding defunctCallLinks=${defunctCallLinks.length}`);
defunctCallLinks.forEach(defunctCallLink => {
const storageRecord: Proto.StorageRecord.Params = {
record: {
callLink: toDefunctOrPendingCallLinkRecord(defunctCallLink),
callLink: toDefunctCallLinkRecord(defunctCallLink),
},
};
@@ -782,15 +764,12 @@ async function generateManifest(
}
});
log.info(
`upload(${version}): ` +
`adding pendingCallLinks=${pendingCallLinks.length}`
);
log.info(`${logId}: adding pendingCallLinks=${pendingCallLinks.length}`);
pendingCallLinks.forEach(pendingCallLink => {
const storageRecord: Proto.StorageRecord.Params = {
record: {
callLink: toDefunctOrPendingCallLinkRecord(pendingCallLink),
callLink: toPendingCallLinkRecord(pendingCallLink),
},
};
@@ -822,7 +801,7 @@ async function generateManifest(
}
});
log.info(`upload(${version}): adding chatFolders=${chatFolders.length}`);
log.info(`${logId}: adding chatFolders=${chatFolders.length}`);
chatFolders.forEach(chatFolder => {
const { isNewItem, storageID } = processStorageRecord({
@@ -858,7 +837,7 @@ async function generateManifest(
const redactedUnknowns = unknownRecordsArray.map(redactExtendedStorageID);
log.info(
`upload(${version}): adding unknown ` +
`${logId}: adding unknown ` +
`records=${JSON.stringify(redactedUnknowns)} ` +
`count=${redactedUnknowns.length}`
);
@@ -876,7 +855,7 @@ async function generateManifest(
const redactedErrors = recordsWithErrors.map(redactExtendedStorageID);
log.info(
`upload(${version}): adding error ` +
`${logId}: adding error ` +
`records=${JSON.stringify(redactedErrors)} count=${redactedErrors.length}`
);
@@ -895,8 +874,7 @@ async function generateManifest(
redactExtendedStorageID
);
log.info(
`upload(${version}): ` +
`deleting extra keys=${JSON.stringify(redactedPendingDeletes)} ` +
`${logId}: deleting extra keys=${JSON.stringify(redactedPendingDeletes)} ` +
`count=${redactedPendingDeletes.length}`
);
@@ -917,8 +895,7 @@ async function generateManifest(
const typeAndID = `${itemType}+${storageID}`;
if (duplicates.has(storageID) || typeDuplicates.has(typeAndID)) {
log.warn(
`upload(${version}): removing from duplicate item ` +
'from the manifest',
`${logId}: removing from duplicate item from the manifest`,
redactStorageID(storageID),
itemType
);
@@ -931,7 +908,7 @@ async function generateManifest(
const hasDeleteKey = deleteKeys.has(storageID);
if (hasDeleteKey) {
log.warn(
`upload(${version}): removing key which has been deleted`,
`${logId}: removing key which has been deleted`,
redactStorageID(storageID),
itemType
);
@@ -942,7 +919,7 @@ async function generateManifest(
if (itemType === ITEM_TYPE.ACCOUNT) {
if (hasAccountType) {
log.warn(
`upload(${version}): removing duplicate account`,
`${logId}: removing duplicate account`,
redactStorageID(storageID)
);
recordsByID.delete(storageID);
@@ -960,7 +937,7 @@ async function generateManifest(
// Ensure there are no duplicate StorageIdentifiers in your list of inserts
if (storageKeyDuplicates.has(storageID)) {
log.warn(
`upload(${version}): removing duplicate identifier from inserts`,
`${logId}: removing duplicate identifier from inserts`,
redactStorageID(storageID)
);
insertKeys.delete(storageID);
@@ -981,7 +958,7 @@ async function generateManifest(
const remoteKeys = new Set<string>();
(previousManifest.identifiers ?? []).forEach(
(identifier: IManifestRecordIdentifier) => {
strictAssert(identifier.raw, 'Identifier without raw field');
strictAssert(identifier.raw, `${logId}: Identifier without raw field`);
const storageID = Bytes.toBase64(identifier.raw);
remoteKeys.add(storageID);
}
@@ -1021,28 +998,32 @@ async function generateManifest(
return redactStorageID(id);
});
log.error(
`upload(${version}): delete key sizes do not match`,
`${logId}: delete key sizes do not match`,
'local',
localDeletes.join(','),
'remote',
remoteDeletes.join(',')
);
throw new Error('invalid write delete keys length do not match');
throw new Error(
`${logId}: invalid write delete keys length do not match`
);
}
if (insertKeys.size !== pendingInserts.size) {
throw new Error('invalid write insert items length do not match');
throw new Error(
`${logId}: invalid write insert items length do not match`
);
}
for (const storageID of deleteKeys) {
if (!pendingDeletes.has(storageID)) {
throw new Error(
'invalid write delete key missing from pending deletes'
`${logId}: invalid write delete key missing from pending deletes`
);
}
}
for (const storageID of insertKeys) {
if (!pendingInserts.has(storageID)) {
throw new Error(
'invalid write insert key missing from pending inserts'
`${logId}: invalid write insert key missing from pending inserts`
);
}
}
@@ -1628,13 +1609,13 @@ async function mergeRecord(
type NonConversationRecordsResultType = Readonly<{
callLinkDbRecords: ReadonlyArray<CallLinkRecord>;
chatFolders: ReadonlyArray<ChatFolder>;
defunctCallLinks: ReadonlyArray<DefunctCallLinkType>;
installedStickerPacks: ReadonlyArray<StickerPackType>;
notificationProfiles: ReadonlyArray<NotificationProfileType>;
pendingCallLinks: ReadonlyArray<PendingCallLinkType>;
installedStickerPacks: ReadonlyArray<StickerPackType>;
uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>;
storyDistributionLists: ReadonlyArray<StoryDistributionWithMembersType>;
chatFolders: ReadonlyArray<ChatFolder>;
uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>;
}>;
// TODO: DESKTOP-3929
@@ -1649,8 +1630,8 @@ async function getNonConversationRecords(): Promise<NonConversationRecordsResult
installedStickerPacks,
chatFolders,
] = await Promise.all([
DataReader.getAllCallLinkRecordsWithAdminKey(),
DataReader.getAllDefunctCallLinksWithAdminKey(),
DataReader.getAllCallLinkRecordsForStorageService(),
DataReader.getAllDefunctCallLinksForStorageService(),
DataReader.getAllNotificationProfiles(),
// FIXME
// oxlint-disable-next-line typescript/await-thenable
@@ -1663,13 +1644,13 @@ async function getNonConversationRecords(): Promise<NonConversationRecordsResult
return {
callLinkDbRecords,
chatFolders,
defunctCallLinks,
installedStickerPacks,
notificationProfiles,
pendingCallLinks,
storyDistributionLists,
uninstalledStickerPacks,
installedStickerPacks,
chatFolders,
};
}
@@ -1677,10 +1658,11 @@ async function processManifest(
manifest: Proto.ManifestRecord,
version: number
): Promise<void> {
const logId = `processManifest/${version}`;
const remoteKeysTypeMap = new Map();
(manifest.identifiers || []).forEach(
({ raw, type }: IManifestRecordIdentifier) => {
strictAssert(raw, 'Identifier without raw field');
strictAssert(raw, `${logId}: Identifier without raw field`);
remoteKeysTypeMap.set(Bytes.toBase64(raw), type);
}
);
@@ -1780,18 +1762,16 @@ async function processManifest(
);
log.info(
`process(${version}): localRecords=${localRecordCount} ` +
`${logId}: localRecords=${localRecordCount} ` +
`localKeys=${localVersions.size} unknownKeys=${stillUnknown.length} ` +
`remoteKeys=${remoteKeys.size}`
);
log.info(
`process(${version}): ` +
`remoteOnlyCount=${remoteOnlySet.size} ` +
`${logId}: remoteOnlyCount=${remoteOnlySet.size} ` +
`remoteOnlyKeys=${JSON.stringify(redactedRemoteOnly)}`
);
log.info(
`process(${version}): ` +
`localOnlyCount=${localOnlySet.size} ` +
`${logId}: localOnlyCount=${localOnlySet.size} ` +
`localOnlyKeys=${JSON.stringify(redactedLocalOnly)}`
);
@@ -1835,8 +1815,7 @@ async function processManifest(
conversation.isUnregistered()
) {
log.info(
`process(${version}): localKey=${missingKey} is ` +
'unregistered and not in remote manifest'
`${logId}/conversation: localKey=${missingKey} is unregistered and not in remote manifest`
);
conversation.setUnregistered({
timestamp: Date.now() - getMessageQueueTime(),
@@ -1847,8 +1826,7 @@ async function processManifest(
});
} else {
log.info(
`process(${version}): localKey=${missingKey} ` +
'was not in remote manifest'
`${logId}/conversation: localKey=${missingKey} was not in remote manifest`
);
}
conversation.set({ storageID: undefined, storageVersion: undefined });
@@ -1861,30 +1839,26 @@ async function processManifest(
{
const {
callLinkDbRecords,
chatFolders,
defunctCallLinks,
installedStickerPacks,
notificationProfiles,
pendingCallLinks,
storyDistributionLists,
installedStickerPacks,
uninstalledStickerPacks,
chatFolders,
} = await getNonConversationRecords();
uninstalledStickerPacks.forEach(stickerPack => {
const { storageID, storageVersion } = stickerPack;
const { id, storageID, storageVersion } = stickerPack;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
`${logId}/uninstalledStickerPack: localKey=${missingKey} was not in remote manifest. Removing.`
);
void DataWriter.addUninstalledStickerPack({
...stickerPack,
storageID: undefined,
storageVersion: undefined,
});
drop(DataWriter.removeUninstalledStickerPack(id));
});
installedStickerPacks.forEach(stickerPack => {
@@ -1895,8 +1869,7 @@ async function processManifest(
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
`${logId}/installedStickerPack: localKey=${missingKey} was not in remote manifest`
);
void DataWriter.updateStickerPackInfo({
id: stickerPack.id,
@@ -1911,15 +1884,26 @@ async function processManifest(
});
storyDistributionLists.forEach(storyDistributionList => {
const { storageID, storageVersion } = storyDistributionList;
const { id, deletedAtTimestamp, storageID, storageVersion } =
storyDistributionList;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(storageID, storageVersion);
if (deletedAtTimestamp) {
log.info(
`${logId}/storyDistributionList: localKey=${missingKey} was not in remote manifest, but deleted locally. Removing.`
);
drop(DataWriter.deleteStoryDistribution(id));
window.reduxActions.storyDistributionLists.distributionListWasDeleted(
id
);
return;
}
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
`${logId}/storyDistributionList: localKey=${missingKey} was not in remote manifest`
);
void DataWriter.modifyStoryDistribution({
...storyDistributionList,
@@ -1934,7 +1918,7 @@ async function processManifest(
);
if (!myStories) {
log.info(`process(${version}): creating my stories`);
log.info(`${logId}: creating my stories`);
const storyDistribution: StoryDistributionWithMembersType = {
allowsReplies: true,
id: MY_STORY_ID,
@@ -1957,18 +1941,22 @@ async function processManifest(
}
callLinkDbRecords.forEach(callLinkDbRecord => {
const { storageID, storageVersion } = callLinkDbRecord;
const { deletedAt, roomId, storageID, storageVersion } = callLinkDbRecord;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(
storageID,
storageVersion || undefined
);
const missingKey = redactStorageID(storageID, storageVersion);
if (deletedAt) {
log.info(
`${logId}/callLinkDbRecord: localKey=${missingKey} was not in remote manifest, but deleted locally. Removing.`
);
drop(DataWriter.deleteCallLink(roomId));
return;
}
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
`${logId}/callLinkDbRecord: localKey=${missingKey} was not in remote manifest`
);
const callLink = callLinkFromRecord(callLinkDbRecord);
drop(
@@ -1981,23 +1969,16 @@ async function processManifest(
});
defunctCallLinks.forEach(defunctCallLink => {
const { storageID, storageVersion } = defunctCallLink;
const { roomId, storageID, storageVersion } = defunctCallLink;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
);
drop(
DataWriter.updateDefunctCallLink({
...defunctCallLink,
storageID: undefined,
storageVersion: undefined,
})
`${logId}/defunctCallLink: localKey=${missingKey} was not in remote manifest. Removing.`
);
drop(DataWriter.deleteDefunctCallLink(roomId));
});
pendingCallLinks.forEach(pendingCallLink => {
@@ -2008,8 +1989,7 @@ async function processManifest(
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
`${logId}/pendingCallLink: localKey=${missingKey} was not in remote manifest`
);
callLinkRefreshJobQueue.updatePendingCallLinkStorageFields(
pendingCallLink.rootKey,
@@ -2022,15 +2002,24 @@ async function processManifest(
});
chatFolders.forEach(chatFolder => {
const { storageID, storageVersion } = chatFolder;
const { deletedAtTimestampMs, id, storageID, storageVersion } =
chatFolder;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(storageID, storageVersion);
if (deletedAtTimestampMs) {
log.info(
`${logId}/chatFolder: localKey=${missingKey} was not in remote manifest, but deleted locally. Removing.`
);
drop(DataWriter.deleteChatFolderById(id));
window.reduxActions.chatFolders.refetchChatFolders();
return;
}
log.info(
`process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
`${logId}/chatFolder: localKey=${missingKey} was not in remote manifest`
);
void DataWriter.updateChatFolder({
@@ -2047,12 +2036,54 @@ async function processManifest(
});
if (!hasCurrentAllChatFolder) {
log.info(`process(${version}): creating all chats chat folder`);
log.info(`${logId}: creating all chats chat folder`);
window.reduxActions.chatFolders.createAllChatsChatFolder();
}
const isNotificationProfileSyncDisabled = itemStorage.get(
'notificationProfileSyncDisabled',
false
);
notificationProfiles.forEach(notificationProfile => {
const { deletedAtTimestampMs, id, storageID, storageVersion } =
notificationProfile;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(storageID, storageVersion);
if (deletedAtTimestampMs) {
log.info(
`${logId}/notificationProfile: localKey=${missingKey} was not in remote manifest, but deleted locally. Removing.`
);
drop(DataWriter.deleteNotificationProfileById(id));
window.reduxActions.notificationProfiles.profileWasRemoved(id);
return;
}
if (isNotificationProfileSyncDisabled) {
log.info(
`${logId}/notificationProfile: localKey=${missingKey} was not in remote manifest, but sync=OFF. Removing.`
);
drop(DataWriter.deleteNotificationProfileById(id));
window.reduxActions.notificationProfiles.profileWasRemoved(id);
return;
}
log.info(
`${logId}/notificationProfile: localKey=${missingKey} was not in remote manifest`
);
const update = {
...notificationProfile,
storageID: undefined,
storageVersion: undefined,
};
drop(DataWriter.updateNotificationProfile(update));
window.reduxActions.notificationProfiles.profileWasUpdated(update);
});
}
log.info(`process(${version}): done`);
log.info(`${logId}: done`);
}
export type FetchRemoteRecordsResultType = Readonly<{
+105 -57
View File
@@ -138,6 +138,8 @@ import { isKnownProtoEnumMember } from '../util/isKnownProtoEnumMember.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
import { getOurAddress } from '../util/sendToGroup.preload.ts';
import { QualifiedAddress } from '../types/QualifiedAddress.std.ts';
import { callLinkCleanupService } from './expiring/callLinkCleanupService.preload.ts';
import { defunctCallLinkCleanupService } from './expiring/defunctCallLinkCleanupService.preload.ts';
const { isEqual } = lodash;
@@ -842,19 +844,16 @@ export function toCallLinkRecord(
};
}
export function toDefunctOrPendingCallLinkRecord(
callLink: DefunctCallLinkType | PendingCallLinkType
export function toPendingCallLinkRecord(
callLink: PendingCallLinkType
): Proto.CallLinkRecord.Params {
const rootKey = toRootKeyBytes(callLink.rootKey);
const adminPasskey = callLink.adminKey
? toAdminKeyBytes(callLink.adminKey)
: null;
strictAssert(rootKey, 'toDefunctOrPendingCallLinkRecord: no rootKey');
strictAssert(
adminPasskey,
'toDefunctOrPendingCallLinkRecord: no adminPasskey'
);
strictAssert(rootKey, 'toPendingCallLinkRecord: no rootKey');
strictAssert(adminPasskey, 'toPendingCallLinkRecord: no adminPasskey');
return {
rootKey,
@@ -864,6 +863,22 @@ export function toDefunctOrPendingCallLinkRecord(
};
}
export function toDefunctCallLinkRecord(
callLink: DefunctCallLinkType
): Proto.CallLinkRecord.Params {
const rootKey = toRootKeyBytes(callLink.rootKey);
const deletedAtTimestampMs = BigInt(callLink.addedAt);
strictAssert(rootKey, 'toDefunctCallLinkRecord: no rootKey');
return {
rootKey,
adminPasskey: null,
deletedAtTimestampMs,
$unknown: fromStorageUnknownFields(callLink.storageUnknownFields),
};
}
function toRecipient(
conversationId: string,
logPrefix: string
@@ -2530,6 +2545,19 @@ export async function mergeStickerPackRecord(
};
}
function getEarliestTimestamp(
...timestamps: Array<number | null | undefined>
): number | undefined {
const normalized = timestamps.filter(
(timestamp): timestamp is number => timestamp != null && timestamp > 0
);
if (normalized.length === 0) {
return undefined;
}
return Math.min(...normalized);
}
export async function mergeCallLinkRecord(
storageID: string,
storageVersion: number,
@@ -2554,6 +2582,7 @@ export async function mergeCallLinkRecord(
const localCallLinkDbRecord =
await DataReader.getCallLinkRecordByRoomId(roomId);
const defunctCallLink = await DataReader.getDefunctCallLinkByRoomId(roomId);
const details = logRecordChanges(
localCallLinkDbRecord == null
@@ -2562,9 +2591,12 @@ export async function mergeCallLinkRecord(
callLinkRecord
);
// Note deletedAtTimestampMs can be 0
const deletedAtTimestampMs = toNumber(callLinkRecord.deletedAtTimestampMs);
const deletedAt = deletedAtTimestampMs || null;
const deletedAt =
getEarliestTimestamp(
toNumber(callLinkRecord.deletedAtTimestampMs),
localCallLinkDbRecord?.deletedAt,
defunctCallLink?.addedAt
) || null;
const shouldDrop = Boolean(
deletedAt && isOlderThan(deletedAt, getMessageQueueTime())
);
@@ -2593,12 +2625,39 @@ export async function mergeCallLinkRecord(
if (!localCallLinkDbRecord) {
if (deletedAt) {
details.push(
`skipping deleted call link with no matching local record deletedAt=${deletedAt}`
);
} else if (await DataReader.defunctCallLinkExists(roomId)) {
details.push('skipping known defunct call link');
} else if (callLinkRefreshJobQueue.hasPendingCallLink(storageID)) {
if (defunctCallLink) {
details.push(
`updating known defunct call link; deletedAt=${deletedAt}`
);
await DataWriter.updateDefunctCallLink({
roomId,
rootKey: rootKeyString,
adminKey: null,
addedAt: deletedAt,
storageID,
storageVersion,
storageUnknownFields: callLinkDbRecord.storageUnknownFields,
storageNeedsSync: false,
});
} else {
details.push(
`creating defunct call link, given no matching local record; deletedAt=${deletedAt}`
);
await DataWriter.insertDefunctCallLink({
roomId,
rootKey: rootKeyString,
adminKey: null,
addedAt: deletedAt,
storageID,
storageVersion,
storageUnknownFields: callLinkDbRecord.storageUnknownFields,
storageNeedsSync: false,
});
drop(
defunctCallLinkCleanupService.trigger('just added defunct call link')
);
}
} else if (callLinkRefreshJobQueue.hasPendingCallLink(rootKeyString)) {
details.push('pending call link refresh, updating storage fields');
callLinkRefreshJobQueue.updatePendingCallLinkStorageFields(
rootKeyString,
@@ -2658,11 +2717,12 @@ export async function mergeCallLinkRecord(
// Deleted in storage but we have it locally: Delete locally too and update redux
if (deletedAt && localCallLinkDbRecord.deleted !== 1) {
// Another device deleted the link and uploaded to storage, and we learned about it
log.info(`${logId}: Discovered deleted call link, deleting locally`);
details.push('deleting locally');
log.info(`${logId}: Discovered deleted call link, marking deleted locally`);
details.push('marking deleted locally');
// No need to delete via RingRTC as we assume the originating device did that already
await DataWriter.deleteCallLinkAndHistory(roomId);
await DataWriter.markCallLinkDeleted(roomId, deletedAt);
window.reduxActions.calling.handleCallLinkDelete({ roomId });
drop(callLinkCleanupService.trigger('marked call link deleted'));
} else if (!deletedAt && localCallLinkDbRecord.deleted === 1) {
// Not deleted in storage, but we've marked it as deleted locally.
// Skip doing anything, we will update things locally after sync.
@@ -2761,6 +2821,14 @@ export async function mergeChatFolderRecord(
const idString = bytesToUuid(remoteChatFolderRecord.id) as ChatFolderId;
const logPrefix = `mergeChatFolderRecord(${redactedStorageID}, idString)`;
const localChatFolder = await DataReader.getChatFolder(idString);
const localDeletedAt = localChatFolder?.deletedAtTimestampMs ?? 0;
const deletedAtTimestampMs: number =
getEarliestTimestamp(
toNumber(remoteChatFolderRecord.deletedAtTimestampMs),
localChatFolder?.deletedAtTimestampMs
) || 0;
const remoteChatFolder: ChatFolder = {
id: idString,
@@ -2783,8 +2851,8 @@ export async function mergeChatFolderRecord(
remoteChatFolderRecord.excludedRecipients ?? [],
logPrefix
),
deletedAtTimestampMs:
toNumber(remoteChatFolderRecord.deletedAtTimestampMs) ?? 0,
deletedAtTimestampMs,
storageID,
storageVersion,
storageUnknownFields:
@@ -2794,34 +2862,19 @@ export async function mergeChatFolderRecord(
storageNeedsSync: false,
};
const localChatFolder = await DataReader.getChatFolder(remoteChatFolder.id);
let deletedAtTimestampMs: number;
const remoteDeletedAt = remoteChatFolder.deletedAtTimestampMs;
const localDeletedAt = localChatFolder?.deletedAtTimestampMs ?? 0;
if (remoteDeletedAt > 0 && localDeletedAt > 0) {
if (remoteDeletedAt < localDeletedAt) {
deletedAtTimestampMs = remoteDeletedAt;
} else {
deletedAtTimestampMs = localDeletedAt;
}
} else if (remoteDeletedAt > 0) {
deletedAtTimestampMs = remoteDeletedAt;
} else if (localDeletedAt > 0) {
deletedAtTimestampMs = localDeletedAt;
} else {
deletedAtTimestampMs = remoteDeletedAt;
}
if (remoteChatFolder.folderType === ChatFolderType.ALL) {
log.info(`${logPrefix}: Updating or inserting all chats folder`);
await DataWriter.upsertAllChatsChatFolderFromSync(remoteChatFolder);
} else if (deletedAtTimestampMs > 0) {
if (localChatFolder == null) {
log.info(
`${logPrefix}: skipping deleted chat folder, no local record found`
`${logPrefix}: creating deleted chat folder with deletedAtTimestampMs=${deletedAtTimestampMs}`
);
await DataWriter.createChatFolder(remoteChatFolder);
drop(
chatFolderCleanupService.trigger(
'mergeChatFolderRecord: created deleted chat folder'
)
);
} else if (localDeletedAt === deletedAtTimestampMs) {
log.info(
@@ -2905,8 +2958,9 @@ export function prepareForDisabledNotificationProfileSync(): {
const notDeletedProfiles = profiles.filter(
profile =>
(profile.storageID && profile.deletedAtTimestampMs == null) ||
profile.deletedAtTimestampMs === 0
profile.storageID &&
(profile.deletedAtTimestampMs == null ||
profile.deletedAtTimestampMs === 0)
);
const toAdd: Array<NotificationProfileType> = [];
@@ -3116,9 +3170,7 @@ export async function mergeNotificationProfileRecord(
: Proto.NotificationProfile.DayOfWeek.UNKNOWN;
})
),
deletedAtTimestampMs: localDeletedAt
? Math.min(localDeletedAt, deletedAt ?? Number.MAX_SAFE_INTEGER)
: dropNull(deletedAt),
deletedAtTimestampMs: getEarliestTimestamp(localDeletedAt, deletedAt),
storageID,
storageVersion,
storageUnknownFields:
@@ -3130,15 +3182,11 @@ export async function mergeNotificationProfileRecord(
window.reduxActions.notificationProfiles;
if (!localProfile) {
if (deletedAt) {
details.push(
`skipping deleted notification profile with no matching local record deletedAt=${deletedAt}`
);
} else {
details.push('created new notification profile');
await DataWriter.createNotificationProfile(newProfile);
profileWasCreated(newProfile);
}
details.push(
`created new notification profile; deletedAtTimestampMs=${newProfile.deletedAtTimestampMs}`
);
await DataWriter.createNotificationProfile(newProfile);
profileWasCreated(newProfile);
return {
details,
+20 -8
View File
@@ -1022,14 +1022,21 @@ type ReadableInterface = {
receivedAt: number
) => string | null;
callLinkExists: (roomId: string) => boolean;
defunctCallLinkExists: (roomId: string) => boolean;
getAllCallLinks: () => ReadonlyArray<CallLinkType>;
getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined;
getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined;
getDefunctCallLinkByRoomId: (
roomId: string
) => DefunctCallLinkType | undefined;
getAllAdminCallLinks: () => ReadonlyArray<CallLinkType>;
getAllCallLinkRecordsWithAdminKey: () => ReadonlyArray<CallLinkRecord>;
getAllDefunctCallLinksWithAdminKey: () => ReadonlyArray<DefunctCallLinkType>;
getAllMarkedDeletedCallLinkRoomIds: () => ReadonlyArray<string>;
getAllCallLinkRecordsForStorageService: () => ReadonlyArray<CallLinkRecord>;
getAllDefunctCallLinksForStorageService: () => ReadonlyArray<DefunctCallLinkType>;
getTimestampOfOldestDefunctCallLink: () =>
| { roomId: string; addedAt: number }
| undefined;
getTimestampOfOldestDeletedCallLink():
| { roomId: string; deletedAt: number }
| undefined;
getMessagesBetween: (
conversationId: string,
options: GetMessagesBetweenOptions
@@ -1319,15 +1326,19 @@ type WritableInterface = {
roomId: string,
callLinkState: CallLinkStateType
) => CallLinkType;
beginDeleteAllCallLinks: () => boolean;
beginDeleteCallLink: (roomId: string) => boolean;
markAllCallLinksDeleted: () => boolean;
markCallLinkDeleted: (roomId: string, deletedAt: number) => boolean;
deleteCallLink: (roomId: string) => boolean;
deleteDefunctCallLink: (roomId: string) => boolean;
deleteCallHistoryByRoomId: (roomid: string) => void;
deleteCallLinkAndHistory: (roomId: string) => void;
finalizeDeleteCallLink: (roomId: string) => void;
deleteExpiredDefunctCallLinks(
messageQueueTime: number
): ReadonlyArray<string>;
deleteExpiredCallLinks(messageQueueTime: number): ReadonlyArray<string>;
_removeAllCallLinks: () => void;
insertDefunctCallLink: (defunctCallLink: DefunctCallLinkType) => void;
updateDefunctCallLink: (defunctCallLink: DefunctCallLinkType) => void;
deleteCallLinkFromSync: (roomId: string) => void;
migrateConversationMessages: (obsoleteId: string, currentId: string) => void;
saveEditedMessage: (
mainMessage: ReadonlyDeep<MessageType>,
@@ -1498,6 +1509,7 @@ type WritableInterface = {
deleteExpiredChatFolders: (
messageQueueTime: number
) => ReadonlyArray<ChatFolderId>;
deleteChatFolderById: (id: ChatFolderId) => void;
createMegaphone: (megaphone: RemoteMegaphoneType) => void;
updateMegaphone: (megaphone: RemoteMegaphoneType) => void;
+38 -16
View File
@@ -213,24 +213,27 @@ import {
} from './Interface.std.ts';
import {
_removeAllCallLinks,
beginDeleteAllCallLinks,
beginDeleteCallLink,
callLinkExists,
defunctCallLinkExists,
deleteCallHistoryByRoomId,
deleteCallLinkAndHistory,
deleteCallLinkFromSync,
finalizeDeleteCallLink,
deleteCallLink,
deleteDefunctCallLink,
deleteExpiredCallLinks,
deleteExpiredDefunctCallLinks,
getAllAdminCallLinks,
getAllCallLinkRecordsWithAdminKey,
getAllCallLinkRecordsForStorageService,
getAllCallLinks,
getAllDefunctCallLinksWithAdminKey,
getAllMarkedDeletedCallLinkRoomIds,
getAllDefunctCallLinksForStorageService,
getCallLinkByRoomId,
getCallLinkRecordByRoomId,
getDefunctCallLinkByRoomId,
getTimestampOfOldestDefunctCallLink,
getTimestampOfOldestDeletedCallLink,
insertCallLink,
insertDefunctCallLink,
insertOrUpdateCallLinkFromSync,
markAllCallLinksDeleted,
markCallLinkDeleted,
updateCallLink,
updateCallLinkState,
updateDefunctCallLink,
@@ -264,6 +267,7 @@ import {
updateChatFolderPositions,
updateChatFolderDeletedAtTimestampMsFromSync,
deleteExpiredChatFolders,
deleteChatFolderById,
} from './server/chatFolders.std.ts';
import {
getAllPinnedMessages,
@@ -529,14 +533,16 @@ export const DataReader: ServerReadableInterface = {
getNextExpiringPinnedMessageAcrossConversations,
callLinkExists,
defunctCallLinkExists,
getAllCallLinks,
getCallLinkByRoomId,
getCallLinkRecordByRoomId,
getDefunctCallLinkByRoomId,
getAllAdminCallLinks,
getAllCallLinkRecordsWithAdminKey,
getAllDefunctCallLinksWithAdminKey,
getAllMarkedDeletedCallLinkRoomIds,
getAllCallLinkRecordsForStorageService,
getAllDefunctCallLinksForStorageService,
getTimestampOfOldestDefunctCallLink,
getTimestampOfOldestDeletedCallLink,
getMessagesBetween,
getNearbyMessageFromDeletedSet,
getMostRecentAddressableMessages,
@@ -685,15 +691,22 @@ export const DataWriter: ServerWritableInterface = {
insertOrUpdateCallLinkFromSync,
updateCallLink,
updateCallLinkState,
beginDeleteAllCallLinks,
beginDeleteCallLink,
markAllCallLinksDeleted,
markCallLinkDeleted,
deleteCallLink,
deleteDefunctCallLink,
deleteCallHistoryByRoomId,
deleteCallLinkAndHistory,
finalizeDeleteCallLink,
_removeAllCallLinks,
deleteCallLinkFromSync,
insertDefunctCallLink,
updateDefunctCallLink,
deleteExpiredCallLinks,
deleteExpiredDefunctCallLinks,
migrateConversationMessages,
saveEditedMessage,
saveEditedMessages,
@@ -791,6 +804,7 @@ export const DataWriter: ServerWritableInterface = {
updateChatFolderDeletedAtTimestampMsFromSync,
markChatFolderDeleted,
deleteExpiredChatFolders,
deleteChatFolderById,
createMegaphone,
updateMegaphone,
@@ -8585,6 +8599,14 @@ function markNotificationProfileDeleted(
): number | undefined {
const now = new Date().getTime();
const existing = getNotificationProfileById(db, id);
if (existing && existing.deletedAtTimestampMs) {
logger.warn(
`markNotificationProfileDeleted: Notification profile ${id} already had deletedAtTimestampMs set`
);
return existing.deletedAtTimestampMs;
}
const [query, parameters] = sql`
UPDATE notificationProfiles
SET
@@ -0,0 +1,48 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { sql } from '../util.std.ts';
import type { WritableDB } from '../Interface.std.ts';
import type { LoggerType } from '../../types/Logging.std.ts';
export default function updateToSchemaVersion1800(
db: WritableDB,
logger: LoggerType
): void {
db.exec(`
ALTER TABLE defunctCallLinks
ADD COLUMN addedAt INTEGER;
`);
const [deleteQuery] = sql`
DELETE FROM defunctCallLinks
WHERE adminKey IS NULL AND storageID IS NULL;
`;
const deleteResult = db.prepare(deleteQuery).run();
logger.info(
`updateToSchemaVersion1800: Deleted ${deleteResult.changes} defunct call links`
);
const now = Date.now();
const [updateQuery, updateParams] = sql`
UPDATE defunctCallLinks
SET addedAt = ${now}, storageNeedsSync = 1
`;
const updateResult = db.prepare(updateQuery).run(updateParams);
logger.info(
`updateToSchemaVersion1800: Updated ${updateResult.changes} defunct call links`
);
db.exec(`
ALTER TABLE defunctCallLinks
ALTER COLUMN addedAt SET NOT NULL;
`);
db.exec(`
CREATE INDEX defunctCallLinks_expiring ON defunctCallLinks(addedAt);
`);
db.exec(`
CREATE INDEX callLinks_expiring ON callLinks(deletedAt);
`);
}
+3
View File
@@ -156,6 +156,7 @@ import updateToSchemaVersion1760 from './1760-delete-story-reply-attachment.std.
import updateToSchemaVersion1770 from './1770-add-blocked-at.std.ts';
import updateToSchemaVersion1780 from './1780-fts-reindex.std.ts';
import updateToSchemaVersion1790 from './1790-notify-for-mentions-if-muted.std.ts';
import updateToSchemaVersion1800 from './1800-deleted-fields-for-defunct-call-links.std.ts';
import { DataWriter } from '../Server.node.ts';
import { strictAssert } from '../../util/assert.std.ts';
@@ -1676,6 +1677,8 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
{ version: 1770, update: updateToSchemaVersion1770 },
{ version: 1780, update: updateToSchemaVersion1780 },
{ version: 1790, update: updateToSchemaVersion1790 },
{ version: 1800, update: updateToSchemaVersion1800 },
];
class DBVersionFromFutureError extends Error {
+122 -76
View File
@@ -253,33 +253,22 @@ export function deleteCallHistoryByRoomId(
);
}
// This should only be called from a sync message to avoid accidentally deleting
// on the client but not the server
export function deleteCallLinkFromSync(db: WritableDB, roomId: string): void {
db.transaction(() => {
const [query, params] = sql`
DELETE FROM callLinks
WHERE roomId = ${roomId};
`;
db.prepare(query).run(params);
deleteCallHistoryByRoomId(db, roomId);
})();
}
/**
* Deletes a non-admin call link from the local database, or if it's an admin call link,
* then marks it for deletion and storage sync.
*
* @returns boolean: True if storage sync is needed; False if not
*/
export function beginDeleteCallLink(db: WritableDB, roomId: string): boolean {
export function markCallLinkDeleted(
db: WritableDB,
roomId: string,
deletedAt: number
): boolean {
return db.transaction(() => {
// If adminKey is null, then we should delete the call link
const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql`
DELETE FROM callLinks
WHERE adminKey IS NULL
WHERE (adminKey IS NULL AND storageID IS NULL)
AND roomId = ${roomId};
`;
@@ -293,8 +282,6 @@ export function beginDeleteCallLink(db: WritableDB, roomId: string): boolean {
return false;
}
const deletedAt = new Date().getTime();
// If the admin key is not null, we should mark it for deletion
const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
sql`
@@ -303,7 +290,7 @@ export function beginDeleteCallLink(db: WritableDB, roomId: string): boolean {
deleted = 1,
deletedAt = ${deletedAt},
storageNeedsSync = 1
WHERE adminKey IS NOT NULL
WHERE (adminKey IS NOT NULL OR storageID IS NOT NULL)
AND deleted IS NOT 1
AND roomId = ${roomId};
`;
@@ -340,27 +327,28 @@ export function deleteCallLinkAndHistory(db: WritableDB, roomId: string): void {
*
* @returns boolean: True if storage sync is needed; False if not
*/
export function beginDeleteAllCallLinks(db: WritableDB): boolean {
export function markAllCallLinksDeleted(db: WritableDB): boolean {
const deletedAt = new Date().getTime();
return db.transaction(() => {
const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
sql`
UPDATE callLinks
SET
deleted = 1,
deletedAt = ${deletedAt},
storageNeedsSync = 1
WHERE adminKey IS NOT NULL
AND deleted IS NOT 1;
`;
UPDATE callLinks
SET
deleted = 1,
deletedAt = ${deletedAt},
storageNeedsSync = 1
WHERE (adminKey IS NOT NULL OR storageID IS NOT NULL)
AND deleted IS NOT 1;
`;
const markAdminCallLinksDeletedResult = db
.prepare(markAdminCallLinksDeletedQuery)
.run(markAdminCallLinksDeletedParams);
// We can delete these immediately because they were never synced to Storage Service
const [deleteNonAdminCallLinksQuery] = sql`
DELETE FROM callLinks
WHERE adminKey IS NULL;
WHERE (adminKey IS NULL AND storageID IS NULL);
`;
db.prepare(deleteNonAdminCallLinksQuery).run();
@@ -371,12 +359,13 @@ export function beginDeleteAllCallLinks(db: WritableDB): boolean {
}
// When you need to access the deleted field
export function getAllCallLinkRecordsWithAdminKey(
export function getAllCallLinkRecordsForStorageService(
db: ReadableDB
): ReadonlyArray<CallLinkRecord> {
const [query] = sql`
SELECT * FROM callLinks
WHERE adminKey IS NOT NULL
WHERE
(adminKey IS NOT NULL OR storageID IS NOT NULL)
AND rootKey IS NOT NULL;
`;
return db
@@ -388,35 +377,11 @@ export function getAllCallLinkRecordsWithAdminKey(
export function getAllAdminCallLinks(
db: ReadableDB
): ReadonlyArray<CallLinkType> {
return getAllCallLinkRecordsWithAdminKey(db).map((record: CallLinkRecord) =>
callLinkFromRecord(record)
return getAllCallLinkRecordsForStorageService(db).map(
(record: CallLinkRecord) => callLinkFromRecord(record)
);
}
export function getAllMarkedDeletedCallLinkRoomIds(
db: ReadableDB
): ReadonlyArray<string> {
const [query] = sql`
SELECT roomId FROM callLinks WHERE deleted = 1;
`;
return db
.prepare(query, {
pluck: true,
})
.all();
}
// TODO: Run this after uploading storage records, maybe periodically on startup
export function finalizeDeleteCallLink(db: WritableDB, roomId: string): void {
const [query, params] = sql`
DELETE FROM callLinks
WHERE roomId = ${roomId}
AND deleted = 1
AND storageNeedsSync = 0;
`;
db.prepare(query).run(params);
}
export function _removeAllCallLinks(db: WritableDB): void {
const [query, params] = sql`
DELETE FROM callLinks;
@@ -424,28 +389,13 @@ export function _removeAllCallLinks(db: WritableDB): void {
db.prepare(query).run(params);
}
export function defunctCallLinkExists(db: ReadableDB, roomId: string): boolean {
const [query, params] = sql`
SELECT 1
FROM defunctCallLinks
WHERE roomId = ${roomId};
`;
return (
db
.prepare(query, {
pluck: true,
})
.get(params) === 1
);
}
export function getAllDefunctCallLinksWithAdminKey(
export function getAllDefunctCallLinksForStorageService(
db: ReadableDB
): ReadonlyArray<DefunctCallLinkType> {
const [query] = sql`
SELECT *
FROM defunctCallLinks
WHERE adminKey IS NOT NULL;
WHERE (adminKey IS NOT NULL OR storageID IS NOT NULL);
`;
return db
.prepare(query)
@@ -455,6 +405,26 @@ export function getAllDefunctCallLinksWithAdminKey(
);
}
export function getDefunctCallLinkByRoomId(
db: ReadableDB,
roomId: string
): DefunctCallLinkType | undefined {
const [query, params] = sql`
SELECT *
FROM defunctCallLinks
WHERE roomId = ${roomId}
`;
const item = db.prepare(query).get(params);
if (!item) {
return undefined;
}
return defunctCallLinkFromRecord(
parseUnknown(defunctCallLinkRecordSchema, item as unknown)
);
}
export function insertDefunctCallLink(
db: WritableDB,
defunctCallLink: DefunctCallLinkType
@@ -469,6 +439,7 @@ export function insertDefunctCallLink(
roomId,
rootKey,
adminKey,
addedAt,
storageID,
storageVersion,
storageUnknownFields,
@@ -477,6 +448,7 @@ export function insertDefunctCallLink(
$roomId,
$rootKey,
$adminKey,
$addedAt,
$storageID,
$storageVersion,
$storageUnknownFields,
@@ -498,8 +470,9 @@ export function updateDefunctCallLink(
// Do not write roomId or rootKey since they should never change
db.prepare(
`
UPDATE callLinks
UPDATE defunctCallLinks
SET
addedAt = $addedAt,
storageID = $storageID,
storageVersion = $storageVersion,
storageUnknownFields = $storageUnknownFields,
@@ -508,3 +481,76 @@ export function updateDefunctCallLink(
`
).run(data);
}
export function getTimestampOfOldestDefunctCallLink(
db: ReadableDB
): { roomId: string; addedAt: number } | undefined {
const [query, params] = sql`
SELECT roomId, addedAt FROM defunctCallLinks
ORDER BY addedAt ASC
LIMIT 1
`;
return db.prepare(query).get(params);
}
// Note: this should only be used in unusual situations; defunct call links will expire
// normally based on addedAt
export function deleteDefunctCallLink(db: WritableDB, roomId: string): boolean {
const [query, params] = sql`
DELETE FROM defunctCallLinks
WHERE roomId = ${roomId}
`;
const result = db.prepare(query).run(params);
return result.changes > 0;
}
export function deleteExpiredDefunctCallLinks(
db: WritableDB,
messageQueueTime: number
): ReadonlyArray<string> {
const before = Date.now() - messageQueueTime;
const [query, params] = sql`
DELETE FROM defunctCallLinks
WHERE addedAt < ${before}
RETURNING roomId
`;
return db.prepare(query, { pluck: true }).all<string>(params);
}
export function getTimestampOfOldestDeletedCallLink(
db: ReadableDB
): { roomId: string; deletedAt: number } | undefined {
const [query, params] = sql`
SELECT roomId, deletedAt FROM callLinks
WHERE deletedAt > 0
ORDER BY deletedAt ASC
LIMIT 1
`;
return db.prepare(query).get(params);
}
// Note: this should only be used in unusual situations; usually we want to mark deleted.
export function deleteCallLink(db: WritableDB, roomId: string): boolean {
const [query, params] = sql`
DELETE FROM callLinks
WHERE roomId = ${roomId}
`;
const result = db.prepare(query).run(params);
return result.changes > 0;
}
export function deleteExpiredCallLinks(
db: WritableDB,
messageQueueTime: number
): ReadonlyArray<string> {
const before = Date.now() - messageQueueTime;
const [query, params] = sql`
DELETE FROM callLinks
WHERE deletedAt > 0
AND deletedAt < ${before}
RETURNING roomId
`;
return db.prepare(query, { pluck: true }).all<string>(params);
}
+9
View File
@@ -387,3 +387,12 @@ export function deleteExpiredChatFolders(
`;
return db.prepare(query, { pluck: true }).all<ChatFolderId>(params);
}
// Note: this should only be used in unusual situations; usually we want to mark deleted
export function deleteChatFolderById(db: WritableDB, id: ChatFolderId): void {
const [query, params] = sql`
DELETE FROM chatFolders
WHERE id = ${id}
`;
db.prepare(query).run(params);
}
+6 -9
View File
@@ -123,7 +123,6 @@ import {
getPresentingSource,
} from '../selectors/calling.std.ts';
import { runStorageServiceUploadJob } from '../../services/storage.preload.ts';
import { CallLinkFinalizeDeleteManager } from '../../jobs/CallLinkFinalizeDeleteManager.preload.ts';
import { callLinkRefreshJobQueue } from '../../jobs/callLinkRefreshJobQueue.preload.ts';
import {
isOnline,
@@ -135,6 +134,7 @@ import { noopAction, type NoopActionType } from './noop.std.ts';
import type { SignalService } from '../../protobuf/index.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
import type { ErrorModalDataProps } from '../../components/ErrorModal.dom.tsx';
import { callLinkCleanupService } from '../../services/expiring/callLinkCleanupService.preload.ts';
const { omit } = lodash;
@@ -2360,7 +2360,10 @@ function deleteCallLink(
return;
}
const isStorageSyncNeeded = await DataWriter.beginDeleteCallLink(roomId);
const isStorageSyncNeeded = await DataWriter.markCallLinkDeleted(
roomId,
Date.now()
);
if (isStorageSyncNeeded) {
runStorageServiceUploadJob({ reason: 'deleteCallLink' });
}
@@ -2368,13 +2371,7 @@ function deleteCallLink(
if (isCallLinkAdmin(callLink)) {
// This throws if call link is active or network is unavailable.
await calling.deleteCallLink(callLink);
// Wait for storage service sync before finalizing delete.
drop(
CallLinkFinalizeDeleteManager.addJob(
{ roomId: callLink.roomId },
{ delay: 10000 }
)
);
drop(callLinkCleanupService.trigger('deleted call link'));
}
await DataWriter.deleteCallHistoryByRoomId(callLink.roomId);
+3 -2
View File
@@ -46,9 +46,10 @@ import {
TOGGLE_DISCARD_DRAFT_DIALOG,
} from './globalModals.preload.ts';
import {
MODIFY_LIST,
DELETE_LIST,
HIDE_MY_STORIES_FROM,
MARK_AS_DELETED,
MODIFY_LIST,
VIEWERS_CHANGED,
} from './storyDistributionLists.preload.ts';
import type { StoryDistributionListsActionType } from './storyDistributionLists.preload.ts';
@@ -6156,7 +6157,7 @@ export function reducer(
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === DELETE_LIST) {
if (action.type === DELETE_LIST || action.type === MARK_AS_DELETED) {
const { listId } = action.payload;
const nextVerificationData = visitListsInVerificationData(
+21 -5
View File
@@ -210,7 +210,7 @@ function markProfileDeleted(
// If called based on a local change, this function is run before the storage service
// upload. If called based on a storage service update, it is called at the end of
// processing, as the AccountRecord is processed. All profiles have been processed at
// that point, and the override from AccountRecord has been processed as well.
// that point, and the override from AccountRecord is just about to be processed.
function setIsSyncEnabled(
enabled: boolean,
{ fromStorageService }: { fromStorageService: boolean }
@@ -316,8 +316,15 @@ function setProfileOverride(
const state = getState();
const currentOverride = getOverride(state);
const isNotificationProfileSyncEnabled = !itemStorage.get(
'notificationProfileSyncDisabled',
false
);
const me = window.ConversationController.getOurConversationOrThrow();
me.captureChange(logId);
if (isNotificationProfileSyncEnabled) {
me.captureChange(logId);
}
if (enabled) {
if (
@@ -344,7 +351,9 @@ function setProfileOverride(
payload: newOverride,
});
fastUpdateProfileService();
updateStorageService(logId);
if (isNotificationProfileSyncEnabled) {
updateStorageService(logId);
}
return;
}
@@ -359,7 +368,9 @@ function setProfileOverride(
payload: newOverride,
});
fastUpdateProfileService();
updateStorageService(logId);
if (isNotificationProfileSyncEnabled) {
updateStorageService(logId);
}
};
}
@@ -400,7 +411,12 @@ function updateOverride(
payload,
});
if (!fromStorageService) {
const isNotificationProfileSyncEnabled = !itemStorage.get(
'notificationProfileSyncDisabled',
false
);
if (!fromStorageService && isNotificationProfileSyncEnabled) {
const me = window.ConversationController.getOurConversationOrThrow();
me.captureChange(logId);
updateStorageService(logId);
@@ -47,6 +47,7 @@ export type StoryDistributionListStateType = ReadonlyDeep<{
const ALLOW_REPLIES_CHANGED = 'storyDistributionLists/ALLOW_REPLIES_CHANGED';
const CREATE_LIST = 'storyDistributionLists/CREATE_LIST';
export const MARK_AS_DELETED = 'storyDistributionLists/MARK_AS_DELETED';
export const DELETE_LIST = 'storyDistributionLists/DELETE_LIST';
export const HIDE_MY_STORIES_FROM =
'storyDistributionLists/HIDE_MY_STORIES_FROM';
@@ -67,11 +68,18 @@ type CreateListActionType = ReadonlyDeep<{
payload: StoryDistributionListDataType;
}>;
type MarkAsDeletedActionType = ReadonlyDeep<{
type: typeof MARK_AS_DELETED;
payload: {
listId: string;
deletedAtTimestamp: number;
};
}>;
type DeleteListActionType = ReadonlyDeep<{
type: typeof DELETE_LIST;
payload: {
listId: string;
deletedAtTimestamp: number;
};
}>;
@@ -108,6 +116,7 @@ export type StoryDistributionListsActionType = ReadonlyDeep<
| AllowRepliesChangedActionType
| CreateListActionType
| DeleteListActionType
| MarkAsDeletedActionType
| HideMyStoriesFromActionType
| ModifyListActionType
| ResetMyStoriesActionType
@@ -208,7 +217,7 @@ function createDistributionList(
function deleteDistributionList(
listId: string
): ThunkAction<void, RootStateType, unknown, DeleteListActionType> {
): ThunkAction<void, RootStateType, unknown, MarkAsDeletedActionType> {
return async (dispatch, getState) => {
const deletedAtTimestamp = Date.now();
@@ -257,7 +266,7 @@ function deleteDistributionList(
runStorageServiceUploadJob({ reason: 'deleteDistributionList' });
dispatch({
type: DELETE_LIST,
type: MARK_AS_DELETED,
payload: {
listId,
deletedAtTimestamp,
@@ -266,6 +275,15 @@ function deleteDistributionList(
};
}
function distributionListWasDeleted(listId: string): DeleteListActionType {
return {
type: DELETE_LIST,
payload: {
listId,
},
};
}
function modifyDistributionList(
distributionList: ModifyDistributionListType
): ModifyListActionType {
@@ -515,6 +533,7 @@ export const actions = {
allowsRepliesChanged,
createDistributionList,
deleteDistributionList,
distributionListWasDeleted,
hideMyStoriesFrom,
modifyDistributionList,
removeMembersFromDistributionList,
@@ -607,7 +626,7 @@ export function reducer(
};
}
if (action.type === DELETE_LIST) {
if (action.type === MARK_AS_DELETED) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
action.payload.listId,
@@ -621,6 +640,15 @@ export function reducer(
return distributionLists ? { distributionLists } : state;
}
if (action.type === DELETE_LIST) {
const { listId } = action.payload;
const distributionLists = state.distributionLists.filter(
item => item.id !== listId
);
return { distributionLists };
}
if (action.type === HIDE_MY_STORIES_FROM) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
@@ -55,8 +55,8 @@ import type { ShowSendAnywayDialogActionType } from '../../../state/ducks/global
import { SHOW_SEND_ANYWAY_DIALOG } from '../../../state/ducks/globalModals.preload.ts';
import type { StoryDistributionListsActionType } from '../../../state/ducks/storyDistributionLists.preload.ts';
import {
DELETE_LIST,
HIDE_MY_STORIES_FROM,
MARK_AS_DELETED,
MODIFY_LIST,
VIEWERS_CHANGED,
} from '../../../state/ducks/storyDistributionLists.preload.ts';
@@ -2376,7 +2376,7 @@ describe('both/state/ducks/conversations', () => {
});
});
});
describe('DELETE_LIST', () => {
describe('MARK_AS_DELETED', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
@@ -2401,7 +2401,7 @@ describe('both/state/ducks/conversations', () => {
it('eliminates deleted list entirely', async () => {
const action: StoryDistributionListsActionType = {
type: DELETE_LIST,
type: MARK_AS_DELETED,
payload: {
deletedAtTimestamp: Date.now(),
listId: LIST_ID_1,
@@ -2443,7 +2443,7 @@ describe('both/state/ducks/conversations', () => {
};
const action: StoryDistributionListsActionType = {
type: DELETE_LIST,
type: MARK_AS_DELETED,
payload: {
deletedAtTimestamp: Date.now(),
listId: LIST_ID_1,
@@ -2475,7 +2475,7 @@ describe('both/state/ducks/conversations', () => {
};
const action: StoryDistributionListsActionType = {
type: DELETE_LIST,
type: MARK_AS_DELETED,
payload: {
deletedAtTimestamp: Date.now(),
listId: LIST_ID_1,
+501
View File
@@ -0,0 +1,501 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { Proto } from '@signalapp/mock-server';
import * as durations from '../../util/durations/index.std.ts';
import { initStorage } from './fixtures.node.ts';
import { debug } from './fixtures.node.ts';
import { constantTimeEqual, getRandomBytes } from '../../Crypto.node.ts';
import type { Bootstrap } from './fixtures.node.ts';
import type { App } from './fixtures.node.ts';
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
describe('storage service/delete', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
beforeEach(async () => {
({ bootstrap, app } = await initStorage());
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
}
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('should roundtrip records even if they were deleted when first discovered', async () => {
const { phone, contacts } = bootstrap;
const alice = contacts[0];
assert.exists(alice);
let state = await phone.expectStorageState('initial state');
debug('adding deleted records to storage service via phone');
const deletedAtTimestamp = BigInt(Date.now() + durations.DAY);
const storyDistributionList = {
type: IdentifierType.STORY_DISTRIBUTION_LIST,
key: Buffer.from(getRandomBytes(16)),
record: {
// if deletedAtTimestamp is set, name and members should not be
storyDistributionList: {
identifier: getRandomBytes(16),
name: null,
deletedAtTimestamp,
allowsReplies: null,
isBlockList: null,
recipientServiceIdsBinary: null,
},
},
};
state = state.addRecord(storyDistributionList);
const stickerPack = {
type: IdentifierType.STICKER_PACK,
key: Buffer.from(getRandomBytes(16)),
record: {
stickerPack: {
packId: getRandomBytes(16),
packKey: getRandomBytes(32),
position: 1,
deletedAtTimestamp,
},
},
};
state = state.addRecord(stickerPack);
const callLink = {
type: IdentifierType.CALL_LINK,
key: Buffer.from(getRandomBytes(16)),
record: {
// if deletedAtTimestampMs is set, adminPassKey should not be
callLink: {
rootKey: getRandomBytes(16),
adminPasskey: null,
deletedAtTimestampMs: deletedAtTimestamp,
},
},
};
state = state.addRecord(callLink);
const chatFolder = {
type: IdentifierType.CHAT_FOLDER,
key: Buffer.from(getRandomBytes(16)),
record: {
chatFolder: {
id: getRandomBytes(16),
name: 'Chat Folder',
position: 4294967295,
showOnlyUnread: null,
showMutedChats: null,
includeAllIndividualChats: null,
includeAllGroupChats: null,
folderType: Proto.ChatFolderRecord.FolderType.CUSTOM,
includedRecipients: null,
excludedRecipients: null,
deletedAtTimestampMs: deletedAtTimestamp,
},
},
};
state = state.addRecord(chatFolder);
const notificationProfile = {
type: IdentifierType.NOTIFICATION_PROFILE,
key: Buffer.from(getRandomBytes(16)),
record: {
notificationProfile: {
id: getRandomBytes(16),
name: 'Notification Profile',
emoji: null,
color: null,
createdAtMs: null,
allowAllCalls: null,
allowAllMentions: null,
allowedMembers: null,
scheduleEnabled: null,
scheduleStartTime: null,
scheduleEndTime: null,
scheduleDaysEnabled: null,
deletedAtTimestampMs: deletedAtTimestamp,
},
},
};
state = state.addRecord(notificationProfile);
state = state.pin(alice);
const updatedState = await phone.setStorageState(state);
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
debug('waiting for Desktop to pick up the change');
await app.waitForManifestVersion(updatedState.version);
const window = await app.getWindow();
const conversationStack = window.locator('.Inbox__conversation-stack');
const leftPane = window.locator('#LeftPane');
debug('verifying that contact is pinned');
await leftPane.locator(`[data-testid="${alice.device.aci}"]`).waitFor();
debug('unpinning via desktop');
{
const convo = leftPane.getByTestId(alice.device.aci);
await convo.click();
const moreButton = conversationStack.getByRole('button', {
name: 'More Info',
});
await moreButton.click();
const pinButton = window.getByRole('menuitem', {
name: 'Unpin chat',
exact: true,
});
await pinButton.click();
}
debug("waiting for desktop's storage service update to get back to phone");
const newState = await phone.waitForStorageState({
after: updatedState,
predicate: maybeState => !maybeState.isPinned(alice),
});
debug(
"validating what's in storage service - deleted items should still be there"
);
assert.isTrue(
newState.hasRecord(
item =>
constantTimeEqual(item.key, storyDistributionList.key) &&
item.record.storyDistributionList?.deletedAtTimestamp ===
storyDistributionList.record.storyDistributionList
?.deletedAtTimestamp
),
'looking for deleted storyDistribution list'
);
assert.isTrue(
newState.hasRecord(
item =>
constantTimeEqual(item.key, stickerPack.key) &&
item.record.stickerPack?.deletedAtTimestamp ===
stickerPack.record.stickerPack?.deletedAtTimestamp
),
'looking for deleted stickerPack list'
);
assert.isTrue(
newState.hasRecord(
item =>
constantTimeEqual(item.key, callLink.key) &&
item.record.callLink?.deletedAtTimestampMs ===
callLink.record.callLink?.deletedAtTimestampMs
),
'looking for deleted callLink list'
);
assert.isTrue(
newState.hasRecord(
item =>
constantTimeEqual(item.key, chatFolder.key) &&
item.record.chatFolder?.deletedAtTimestampMs ===
chatFolder.record.chatFolder?.deletedAtTimestampMs
),
'looking for deleted chatFolder list'
);
assert.isTrue(
newState.hasRecord(
item =>
constantTimeEqual(item.key, notificationProfile.key) &&
item.record.notificationProfile?.deletedAtTimestampMs ===
notificationProfile.record.notificationProfile?.deletedAtTimestampMs
),
'looking for deleted notificationProfile list'
);
});
it('should not restore records if they were deleted and removed from storage service', async () => {
const { phone, contacts } = bootstrap;
const alice = contacts[0];
assert.exists(alice);
let initialState = await phone.expectStorageState('initial state');
debug('adding deleted records to storage service via phone');
const deletedAtTimestamp = BigInt(Date.now() + durations.DAY);
const storyDistributionList = {
type: IdentifierType.STORY_DISTRIBUTION_LIST,
key: Buffer.from(getRandomBytes(16)),
record: {
// if deletedAtTimestamp is set, name and members should not be
storyDistributionList: {
identifier: getRandomBytes(16),
name: null,
deletedAtTimestamp,
allowsReplies: null,
isBlockList: null,
recipientServiceIdsBinary: null,
},
},
};
initialState = initialState.addRecord(storyDistributionList);
const stickerPack = {
type: IdentifierType.STICKER_PACK,
key: Buffer.from(getRandomBytes(16)),
record: {
stickerPack: {
packId: getRandomBytes(16),
packKey: getRandomBytes(32),
position: 1,
deletedAtTimestamp,
},
},
};
initialState = initialState.addRecord(stickerPack);
const callLink = {
type: IdentifierType.CALL_LINK,
key: Buffer.from(getRandomBytes(16)),
record: {
// if deletedAtTimestampMs is set, adminPassKey should not be
callLink: {
rootKey: getRandomBytes(16),
adminPasskey: null,
deletedAtTimestampMs: deletedAtTimestamp,
},
},
};
initialState = initialState.addRecord(callLink);
const chatFolder = {
type: IdentifierType.CHAT_FOLDER,
key: Buffer.from(getRandomBytes(16)),
record: {
chatFolder: {
id: getRandomBytes(16),
name: 'Chat Folder',
position: 4294967295,
showOnlyUnread: null,
showMutedChats: null,
includeAllIndividualChats: null,
includeAllGroupChats: null,
folderType: Proto.ChatFolderRecord.FolderType.CUSTOM,
includedRecipients: null,
excludedRecipients: null,
deletedAtTimestampMs: deletedAtTimestamp,
},
},
};
initialState = initialState.addRecord(chatFolder);
const notificationProfile = {
type: IdentifierType.NOTIFICATION_PROFILE,
key: Buffer.from(getRandomBytes(16)),
record: {
notificationProfile: {
id: getRandomBytes(16),
name: 'Notification Profile',
emoji: null,
color: null,
createdAtMs: null,
allowAllCalls: null,
allowAllMentions: null,
allowedMembers: null,
scheduleEnabled: null,
scheduleStartTime: null,
scheduleEndTime: null,
scheduleDaysEnabled: null,
deletedAtTimestampMs: deletedAtTimestamp,
},
},
};
initialState = initialState.addRecord(notificationProfile);
initialState = initialState.pin(alice);
const firstPhoneState = await phone.setStorageState(initialState);
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
debug('waiting for Desktop to pick up the first change');
await app.waitForManifestVersion(firstPhoneState.version);
const window = await app.getWindow();
const conversationStack = window.locator('.Inbox__conversation-stack');
const leftPane = window.locator('#LeftPane');
debug('verifying that contact is pinned');
await leftPane.locator(`[data-testid="${alice.device.aci}"]`).waitFor();
debug('unpinning via desktop');
{
const convo = leftPane.getByTestId(alice.device.aci);
await convo.click();
const moreButton = conversationStack.getByRole('button', {
name: 'More Info',
});
await moreButton.click();
const pinButton = window.getByRole('menuitem', {
name: 'Unpin chat',
exact: true,
});
await pinButton.click();
}
debug("waiting for desktop's storage service update to get back to phone");
let updateState = await phone.waitForStorageState({
after: firstPhoneState,
predicate: maybeState => !maybeState.isPinned(alice),
});
debug('now removing items from storage service via phone');
updateState = updateState.removeRecord(
item =>
constantTimeEqual(item.key, storyDistributionList.key) &&
item.record.storyDistributionList?.deletedAtTimestamp ===
storyDistributionList.record.storyDistributionList?.deletedAtTimestamp
);
updateState = updateState.removeRecord(
item =>
constantTimeEqual(item.key, stickerPack.key) &&
item.record.stickerPack?.deletedAtTimestamp ===
stickerPack.record.stickerPack?.deletedAtTimestamp
);
updateState = updateState.removeRecord(
item =>
constantTimeEqual(item.key, callLink.key) &&
item.record.callLink?.deletedAtTimestampMs ===
callLink.record.callLink?.deletedAtTimestampMs
);
updateState = updateState.removeRecord(
item =>
constantTimeEqual(item.key, chatFolder.key) &&
item.record.chatFolder?.deletedAtTimestampMs ===
chatFolder.record.chatFolder?.deletedAtTimestampMs
);
updateState = updateState.removeRecord(
item =>
constantTimeEqual(item.key, notificationProfile.key) &&
item.record.notificationProfile?.deletedAtTimestampMs ===
notificationProfile.record.notificationProfile?.deletedAtTimestampMs
);
updateState = updateState.pin(alice);
const secondPhoneState = await phone.setStorageState(updateState);
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
debug('waiting for Desktop to pick up the second change');
await app.waitForManifestVersion(secondPhoneState.version);
debug('verifying that contact is pinned');
await leftPane.locator(`[data-testid="${alice.device.aci}"]`).waitFor();
debug('unpinning via desktop');
{
const convo = leftPane.getByTestId(alice.device.aci);
await convo.click();
const moreButton = conversationStack.getByRole('button', {
name: 'More Info',
});
await moreButton.click();
const pinButton = window.getByRole('menuitem', {
name: 'Unpin chat',
exact: true,
});
await pinButton.click();
}
debug("waiting for desktop's storage service update to get back to phone");
const thirdPhoneState = await phone.waitForStorageState({
after: secondPhoneState,
predicate: maybeState => !maybeState.isPinned(alice),
});
debug(
"validating what's in storage service - deleted items should be removed"
);
assert.isFalse(
thirdPhoneState.hasRecord(item => {
const itemId = item.record.storyDistributionList?.identifier;
const expectedId =
storyDistributionList.record.storyDistributionList?.identifier;
if (!itemId || !expectedId) {
return false;
}
return constantTimeEqual(itemId, expectedId);
}),
"don't want to find deleted storyDistribution list"
);
assert.isFalse(
thirdPhoneState.hasRecord(item => {
const itemId = item.record.stickerPack?.packId;
const expectedId = stickerPack.record.stickerPack?.packId;
if (!itemId || !expectedId) {
return false;
}
return constantTimeEqual(itemId, expectedId);
}),
"don't want to find deleted stickerPack list"
);
assert.isFalse(
thirdPhoneState.hasRecord(item => {
const itemId = item.record.callLink?.rootKey;
const expectedId = callLink.record.callLink?.rootKey;
if (!itemId || !expectedId) {
return false;
}
return constantTimeEqual(itemId, expectedId);
}),
"don't want to find deleted callLink list"
);
assert.isFalse(
thirdPhoneState.hasRecord(item => {
const itemId = item.record.chatFolder?.id;
const expectedId = chatFolder.record.chatFolder?.id;
if (!itemId || !expectedId) {
return false;
}
return constantTimeEqual(itemId, expectedId);
}),
"don't want to find deleted chatFolder list"
);
assert.isFalse(
thirdPhoneState.hasRecord(item => {
const itemId = item.record.notificationProfile?.id;
const expectedId = notificationProfile.record.notificationProfile?.id;
if (!itemId || !expectedId) {
return false;
}
return constantTimeEqual(itemId, expectedId);
}),
"don't want to find deleted notificationProfile list"
);
});
});
@@ -0,0 +1,113 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { sortBy } from 'lodash';
import type { WritableDB } from '../../sql/Interface.std.ts';
import {
createDB,
updateToVersion,
insertData,
getTableData,
} from './helpers.node.ts';
import { getRandomBytes } from '../../Crypto.node.ts';
describe('SQL/updateToSchemaVersion1800', () => {
let db: WritableDB;
beforeEach(() => {
db = createDB();
});
afterEach(() => {
db.close();
});
it('adds and sets addedAt, sets storageNeedsSync, deletes if missing both adminKey and storageId', () => {
updateToVersion(db, 1790);
const initialData = [
{
roomId: 'roomId1 (to be deleted)',
rootKey: Buffer.from(getRandomBytes(32)),
adminKey: null,
storageID: null,
storageVersion: null,
storageUnknownFields: null,
storageNeedsSync: 0,
},
{
roomId: 'roomId2',
rootKey: Buffer.from(getRandomBytes(32)),
adminKey: Buffer.from(getRandomBytes(32)),
storageID: null,
storageVersion: null,
storageUnknownFields: null,
storageNeedsSync: 0,
},
{
roomId: 'roomId3',
rootKey: Buffer.from(getRandomBytes(32)),
adminKey: Buffer.from(getRandomBytes(32)),
storageID: 'storageId3',
storageVersion: 3,
storageUnknownFields: Buffer.from(getRandomBytes(32)),
storageNeedsSync: 0,
},
{
roomId: 'roomId4',
rootKey: Buffer.from(getRandomBytes(32)),
adminKey: null,
storageID: 'storageId4',
storageVersion: 4,
storageUnknownFields: null,
storageNeedsSync: 0,
},
];
insertData(db, 'defunctCallLinks', initialData);
const now = Date.now();
updateToVersion(db, 1800);
const actual = sortBy(getTableData(db, 'defunctCallLinks'));
assert.lengthOf(actual, 3);
const item1 = actual[0];
assert.strictEqual(item1?.roomId, 'roomId2');
assert.isAtLeast(item1?.addedAt as number, now);
assert.isAtLeast(item1?.storageNeedsSync as number, 1);
const item2 = actual[1];
assert.strictEqual(item2?.roomId, 'roomId3');
assert.isNumber(item2?.addedAt);
assert.isAtLeast(item2?.addedAt as number, now);
assert.isAtLeast(item1?.storageNeedsSync as number, 1);
const item3 = actual[2];
assert.strictEqual(item3?.roomId, 'roomId4');
assert.isNumber(item3?.addedAt);
assert.isAtLeast(item3?.addedAt as number, now);
assert.isAtLeast(item1?.storageNeedsSync as number, 1);
});
it('addes a NOT NULL constraint without a table rebuild', () => {
updateToVersion(db, 1790);
const beforeResult = db
.prepare(
"SELECT sql from sqlite_schema WHERE name = 'defunctCallLinks'",
{ pluck: true }
)
.get();
assert.notMatch(beforeResult, /addedAt INTEGER NOT NULL/);
updateToVersion(db, 1800);
const afterResult = db
.prepare(
"SELECT sql from sqlite_schema WHERE name = 'defunctCallLinks'",
{ pluck: true }
)
.get();
assert.match(afterResult as string, /addedAt INTEGER NOT NULL/);
});
});
+1
View File
@@ -3558,6 +3558,7 @@ export default class MessageReceiver
type: callLinkUpdateSyncType,
rootKey,
adminKey,
timestamp: envelope.timestamp,
},
this.#removeFromCache.bind(this, envelope)
);
@@ -549,6 +549,7 @@ export type CallLinkUpdateSyncEventData = Readonly<{
type: CallLinkUpdateSyncType;
rootKey: Uint8Array<ArrayBuffer> | undefined;
adminKey: Uint8Array<ArrayBuffer> | undefined;
timestamp: number;
}>;
export class CallLinkUpdateSyncEvent extends ConfirmableEvent {
+3
View File
@@ -90,6 +90,7 @@ export type DefunctCallLinkType = Readonly<{
roomId: string;
rootKey: string;
adminKey: string | null;
addedAt: number;
}> &
StorageServiceFieldsType;
@@ -97,6 +98,7 @@ export type DefunctCallLinkRecord = Readonly<{
roomId: string;
rootKey: Uint8Array<ArrayBuffer>;
adminKey: Uint8Array<ArrayBuffer> | null;
addedAt: number;
storageID: string | null;
storageVersion: number | null;
storageUnknownFields: Uint8Array<ArrayBuffer> | null;
@@ -107,6 +109,7 @@ export const defunctCallLinkRecordSchema = z.object({
roomId: z.string(),
rootKey: z.instanceof(Uint8Array),
adminKey: z.instanceof(Uint8Array).nullable(),
addedAt: z.number(),
storageID: z.string().nullable(),
storageVersion: z.number().int().nullable(),
storageUnknownFields: z.instanceof(Uint8Array).nullable(),
+3 -9
View File
@@ -64,7 +64,6 @@ import type { ConversationModel } from '../models/conversations.preload.ts';
import { drop } from './drop.std.ts';
import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync.preload.ts';
import { runStorageServiceUploadJob } from '../services/storage.preload.ts';
import { CallLinkFinalizeDeleteManager } from '../jobs/CallLinkFinalizeDeleteManager.preload.ts';
import { parseLoose, parseStrict } from './schemas.std.ts';
import { calling } from '../services/calling.preload.ts';
import { cleanupMessages } from './cleanup.preload.ts';
@@ -74,6 +73,7 @@ import { update as updateExpiringMessagesService } from '../services/expiringMes
import type { DurationInSeconds } from './durations/duration-in-seconds.std.ts';
import { isFeaturedEnabledNoRedux } from './isFeatureEnabled.dom.ts';
import type { GetUnreadCallMessagesAndMarkReadResult } from '../sql/Interface.std.ts';
import { callLinkCleanupService } from '../services/expiring/callLinkCleanupService.preload.ts';
const { isEqual } = lodash;
@@ -1463,7 +1463,7 @@ export async function clearCallHistoryDataAndSync(
);
// This skips call history for admin call links.
const messageIds = await DataWriter.clearCallHistory(latestCall);
const isStorageSyncNeeded = await DataWriter.beginDeleteAllCallLinks();
const isStorageSyncNeeded = await DataWriter.markAllCallLinksDeleted();
if (isStorageSyncNeeded) {
runStorageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' });
}
@@ -1489,19 +1489,13 @@ export async function clearCallHistoryDataAndSync(
await calling.deleteCallLink(callLink);
// oxlint-disable-next-line no-await-in-loop
await DataWriter.deleteCallHistoryByRoomId(callLink.roomId);
// Wait for storage service sync before finalizing delete.
drop(
CallLinkFinalizeDeleteManager.addJob(
{ roomId: callLink.roomId },
{ delay: 10000 }
)
);
successCount += 1;
} catch (error) {
log.warn('clearCallHistory: Failed to delete admin call link', error);
failCount += 1;
}
}
drop(callLinkCleanupService.trigger('deleted all call links'));
log.info(
`clearCallHistory: Deleted admin call links, success=${successCount} failed=${failCount}`
);
+2
View File
@@ -146,6 +146,7 @@ export function defunctCallLinkFromRecord(
roomId: record.roomId,
rootKey,
adminKey,
addedAt: record.addedAt,
storageID: record.storageID || undefined,
storageVersion: record.storageVersion || undefined,
storageUnknownFields: record.storageUnknownFields || undefined,
@@ -168,6 +169,7 @@ export function defunctCallLinkToRecord(
roomId: defunctCallLink.roomId,
rootKey,
adminKey,
addedAt: defunctCallLink.addedAt,
storageID: defunctCallLink.storageID || null,
storageVersion: defunctCallLink.storageVersion || null,
storageUnknownFields: defunctCallLink.storageUnknownFields || null,
+32 -5
View File
@@ -2,14 +2,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkUpdateSyncEvent } from '../textsecure/messageReceiverEvents.std.ts';
import { createLogger } from '../logging/log.std.ts';
import * as Errors from '../types/errors.std.ts';
import { fromAdminKeyBytes } from './callLinks.std.ts';
import { getRoomIdFromRootKey } from './callLinksRingrtc.node.ts';
import { strictAssert } from './assert.std.ts';
import { CallLinkUpdateSyncType } from '../types/CallLink.std.ts';
import { DataWriter } from '../sql/Client.preload.ts';
import { DataWriter, DataReader } from '../sql/Client.preload.ts';
import { drop } from './drop.std.ts';
import type { CallLinkUpdateSyncEvent } from '../textsecure/messageReceiverEvents.std.ts';
import { callLinkCleanupService } from '../services/expiring/callLinkCleanupService.preload.ts';
import { defunctCallLinkCleanupService } from '../services/expiring/defunctCallLinkCleanupService.preload.ts';
const log = createLogger('onCallLinkUpdateSync');
@@ -17,7 +21,7 @@ export async function onCallLinkUpdateSync(
syncEvent: CallLinkUpdateSyncEvent
): Promise<void> {
const { callLinkUpdate, confirm } = syncEvent;
const { type, rootKey, adminKey } = callLinkUpdate;
const { type, rootKey, adminKey, timestamp } = callLinkUpdate;
if (!rootKey) {
log.warn('Missing rootKey, invalid sync message');
@@ -50,8 +54,31 @@ export async function onCallLinkUpdateSync(
adminKey: adminKeyString,
});
} else if (type === CallLinkUpdateSyncType.Delete) {
log.info(`${logId}: Deleting call link record ${roomId}`);
await DataWriter.deleteCallLinkFromSync(roomId);
if (await DataReader.callLinkExists(roomId)) {
log.info(`${logId}: Marking call link ${roomId} deleted`);
await DataWriter.markCallLinkDeleted(roomId, timestamp);
drop(
callLinkCleanupService.trigger('onCallLinkUpdateSync, marked deleted')
);
} else {
const defunctCallLink =
await DataReader.getDefunctCallLinkByRoomId(roomId);
if (defunctCallLink && defunctCallLink.addedAt > timestamp) {
log.info(
`${logId}: Updating timestamp for defunct call link ${roomId}`
);
const updated = { ...defunctCallLink, addedAt: timestamp };
await DataWriter.updateDefunctCallLink(updated);
drop(
defunctCallLinkCleanupService.trigger(
'onCallLinkUpdateSync, updated addedAt'
)
);
} else if (!defunctCallLink) {
log.info(`${logId}: No local record for deleted call link ${roomId}`);
}
}
window.reduxActions.calling.handleCallLinkDelete({ roomId });
}