Data storage improvements after deleting and leaving a group

Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
This commit is contained in:
Scott Nonnenberg
2026-07-22 04:38:31 +10:00
committed by GitHub
parent 5fb6ce2f89
commit 8efd06f9d8
19 changed files with 1253 additions and 69 deletions
+8 -2
View File
@@ -1,6 +1,8 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { cwd } from 'node:process';
import lodash from 'lodash';
import SQL from '@signalapp/sqlcipher';
@@ -17,7 +19,11 @@ export function createDB(): WritableDB {
return db;
}
export function updateToVersion(db: WritableDB, version: number): void {
export function updateToVersion(
db: WritableDB,
version: number,
data: { userDataPath: string } = { userDataPath: cwd() }
): void {
const startVersion = db.pragma('user_version', { simple: true }) as number;
if (startVersion === version) {
return;
@@ -34,7 +40,7 @@ export function updateToVersion(db: WritableDB, version: number): void {
}
db.transaction(() => {
update(db, silentLogger, startVersion);
update(db, silentLogger, startVersion, data);
db.pragma(`user_version = ${version}`);
})();
@@ -1,6 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { cwd } from 'node:process';
import { assert } from 'chai';
import type { WritableDB } from '../../sql/Interface.std.ts';
@@ -15,7 +16,7 @@ describe('SQL/incrementMessagesMigrationAttempts', () => {
beforeEach(() => {
db = createDB();
setupTests(db);
setupTests(db, { userDataPath: cwd() });
});
afterEach(() => {
@@ -1,6 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { cwd } from 'node:process';
import { assert } from 'chai';
import type { WritableDB } from '../../sql/Interface.std.ts';
@@ -15,7 +16,7 @@ describe('SQL/migrateConversationMessages', () => {
beforeEach(() => {
db = createDB();
setupTests(db);
setupTests(db, { userDataPath: cwd() });
});
afterEach(() => {
@@ -0,0 +1,343 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import fsExtra from 'fs-extra';
import {
createDB,
getTableData,
insertData,
updateToVersion,
} from './helpers.node.ts';
import type { WritableDB } from '../../sql/Interface.std.ts';
import {
createAbsolutePathGetter,
type ConversationAttributesType,
} from '../../sql/migrations/1740-cleanup-groups.node.ts';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { mkdtempSync, readdirSync, rmdirSync } from 'node:fs';
import { missingCaseError } from '../../util/missingCaseError.std.ts';
import {
getAttachmentsPath,
getAvatarsPath,
getDraftPath,
} from '../../../app/attachments.node.ts';
const { emptyDir, ensureFile } = fsExtra;
type TestAttachmentTypes = 'attachment' | 'avatar' | 'draft';
describe('SQL/updateToSchemaVersion1740', () => {
let db: WritableDB;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'Signal'));
db = createDB();
updateToVersion(db, 1730, { userDataPath: tempDir });
});
afterEach(async () => {
db.close();
await emptyDir(tempDir);
rmdirSync(tempDir);
});
it('cleans up only left/terminated and deleted groups', async () => {
const attachmentsPath = getAttachmentsPath(tempDir);
const avatarsPath = getAvatarsPath(tempDir);
const draftPath = getDraftPath(tempDir);
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath);
const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath);
function getAbsolutePath(path: string, type: TestAttachmentTypes) {
switch (type) {
case 'attachment':
return getAbsoluteAttachmentPath(path);
case 'avatar':
return getAbsoluteAvatarPath(path);
case 'draft':
return getAbsoluteDraftPath(path);
default:
throw missingCaseError(type);
}
}
async function writeFile(path: string, type: TestAttachmentTypes) {
await ensureFile(getAbsolutePath(path, type));
}
async function writeFiles(num: number, type: TestAttachmentTypes) {
for (let i = 0; i < num; i += 1) {
// oxlint-disable-next-line no-await-in-loop
await writeFile(`${type}${i}`, type);
}
}
function listFiles(type: TestAttachmentTypes): Array<string> {
return readdirSync(dirname(getAbsolutePath('not used', type)));
}
await writeFiles(3, 'attachment');
await writeFiles(6, 'draft');
await writeFiles(6, 'avatar');
const now = Date.now();
const initialData: Array<{
id: string;
type: string;
name: string;
members?: string;
active_at?: number;
expireTimerVersion: number;
json: Partial<ConversationAttributesType>;
}> = [
{
id: 'c0',
type: 'private',
name: 'John',
expireTimerVersion: 1,
json: {
id: 'c0',
type: 'private',
timestamp: now,
name: 'John',
},
},
{
id: 'c1',
type: 'private',
name: 'John, deleted',
expireTimerVersion: 1,
json: {
id: 'c1',
type: 'private',
timestamp: now,
name: 'John',
messagesDeleted: true,
},
},
{
id: 'c2',
type: 'group',
name: 'GV1 group',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c2',
type: 'group',
groupId: '249qw4kh23492p34',
timestamp: now,
name: 'GV1 group',
members: ['1', '2', '3'],
},
},
{
id: 'c3',
type: 'group',
active_at: now,
name: 'GV2 group with activeAt',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c3',
type: 'group',
groupId: getRandomGroupId(),
groupVersion: 2,
name: 'GV2 group with activeAt',
timestamp: now,
active_at: now,
members: ['1', '2', '3'],
},
},
{
id: 'c4',
type: 'group',
name: 'GV2 group with messagesDeleted',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c4',
type: 'group',
groupId: getRandomGroupId(),
groupVersion: 2,
name: 'GV2 group with messagesDeleted',
timestamp: now,
members: ['1', '2', '3'],
},
},
{
id: 'c5',
type: 'group',
name: 'GV2 group, left',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c5',
type: 'group',
groupId: getRandomGroupId(),
groupVersion: 2,
name: 'GV2 group, left',
timestamp: now,
active_at: now,
members: ['1', '2', '3'],
left: true,
},
},
{
id: 'c6',
type: 'group',
active_at: now,
name: 'GV2 group, terminated',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c6',
type: 'group',
groupId: getRandomGroupId(),
groupVersion: 2,
name: 'GV2 group, terminated',
timestamp: now,
active_at: now,
members: ['1', '2', '3'],
terminated: true,
},
},
{
id: 'c7',
type: 'group',
name: 'GV2 group, deleted but not left or terminated',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c7',
type: 'group',
groupId: getRandomGroupId(),
avatar: {
path: 'attachment0',
},
draftAttachments: [{ path: 'draft0' }, { screenshotPath: 'draft1' }],
avatars: [
{ id: 0, imagePath: 'avatar0' },
{ id: 1 },
{ id: 2, imagePath: 'avatar1' },
],
groupVersion: 2,
name: 'GV2 group, deleted but not left or terminated',
timestamp: now,
messagesDeleted: true,
members: ['1', '2', '3'],
},
},
{
id: 'c8',
type: 'group',
name: 'GV2 group, left and deleted - will be erased',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c8',
type: 'group',
groupId: getRandomGroupId(),
groupVersion: 2,
name: 'GV2 group, left and deleted - will be erased',
timestamp: now,
avatar: {
path: 'attachment1',
},
draftAttachments: [{ path: 'draft2' }, { screenshotPath: 'draft3' }],
avatars: [
{ id: 0, imagePath: 'avatar2' },
{ id: 1 },
{ id: 2, imagePath: 'avatar3' },
],
messagesDeleted: true,
members: ['1', '2', '3'],
left: true,
},
},
{
id: 'c9',
type: 'group',
name: 'GV2 group, terminated and deleted - will be erased',
members: '1 2 3',
expireTimerVersion: 1,
json: {
id: 'c9',
type: 'group',
groupId: getRandomGroupId(),
groupVersion: 2,
name: 'GV2 group, terminated and deleted - will be erased',
timestamp: now,
avatar: {
path: 'attachment2',
},
draftAttachments: [{ path: 'draft4' }, { screenshotPath: 'draft5' }],
avatars: [
{ id: 0, imagePath: 'avatar4' },
{ id: 1 },
{ id: 2, imagePath: 'avatar5' },
],
messagesDeleted: true,
members: ['1', '2', '3'],
terminated: true,
},
},
];
insertData(db, 'conversations', initialData);
updateToVersion(db, 1740, { userDataPath: tempDir });
assert.deepStrictEqual(getTableData(db, 'conversations'), [
initialData[0],
initialData[1],
initialData[2],
initialData[3],
initialData[4],
initialData[5],
initialData[6],
initialData[7],
{
id: 'c8',
type: 'group',
expireTimerVersion: 1,
json: {
id: 'c8',
type: 'group',
groupId: initialData[8]?.json.groupId,
groupVersion: 2,
messagesDeleted: true,
expireTimerVersion: 1,
},
},
{
id: 'c9',
type: 'group',
expireTimerVersion: 1,
json: {
id: 'c9',
type: 'group',
groupId: initialData[9]?.json.groupId,
groupVersion: 2,
messagesDeleted: true,
expireTimerVersion: 1,
},
},
]);
assert.deepEqual(listFiles('attachment'), ['attachment0']);
assert.deepEqual(listFiles('draft'), ['draft0', 'draft1']);
assert.deepEqual(listFiles('avatar'), ['avatar0', 'avatar1']);
});
});
function getRandomGroupId() {
return randomBytes(32).toBase64();
}
@@ -1,6 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'node:assert/strict';
import { cwd } from 'node:process';
import type { WritableDB } from '../../../sql/Interface.std.ts';
import { setupTests } from '../../../sql/Server.node.ts';
import type { AppendPinnedMessageResult } from '../../../sql/server/pinnedMessages.std.ts';
@@ -50,7 +52,7 @@ describe('sql/server/pinnedMessages', () => {
beforeEach(() => {
db = createDB();
setupTests(db);
setupTests(db, { userDataPath: cwd() });
setupData(db);
});