Move database work off the main thread on Node.js 24 and improve message handling and runtime diagnostics. Preserve migration, restart, and concurrency coverage with the async SQLite driver.
1251 lines
47 KiB
TypeScript
1251 lines
47 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { MessageStore } from '../src/message-store';
|
|
import { MessageStoreMigrator, prepareMessageStoreDatabase } from '../src/message-store-migration';
|
|
import { hashMessageIdInt32, GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from '../src/message-id';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
|
|
describe('MessageStore', () => {
|
|
const testDbPath = path.join('data', 'test', 'messages-test.db');
|
|
let store: MessageStore;
|
|
|
|
beforeEach(async () => {
|
|
// Clean up any existing test database
|
|
try {
|
|
fs.unlinkSync(testDbPath);
|
|
} catch {
|
|
// Ignore if file doesn't exist
|
|
}
|
|
|
|
store = new MessageStore(testDbPath);
|
|
await store.initialization;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await store.close();
|
|
|
|
// Clean up test database
|
|
try {
|
|
fs.unlinkSync(testDbPath);
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('resolveReplySequence', () => {
|
|
it('resolves group message reply sequence', async () => {
|
|
const groupId = 123456;
|
|
const sequence = 100;
|
|
const messageId = hashMessageIdInt32(sequence, groupId, GROUP_MESSAGE_EVENT);
|
|
|
|
// Store a group message
|
|
await store.storeMeta(messageId, {
|
|
isGroup: true,
|
|
targetId: groupId,
|
|
sequence,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Resolve the reply sequence
|
|
const resolved = await store.resolveReplySequence(true, groupId, messageId);
|
|
expect(resolved).toBe(sequence);
|
|
});
|
|
|
|
it('resolves private message reply sequence without session_id matching', async () => {
|
|
// Simulate receiving a private message from user 111111
|
|
const senderUin = 111111;
|
|
const sequence = 200;
|
|
const messageId = hashMessageIdInt32(sequence, senderUin, PRIVATE_MESSAGE_EVENT);
|
|
|
|
// Store the received message (session_id is sender's UIN)
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: senderUin,
|
|
sequence,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// When replying to this message, we send to the sender (who becomes the recipient)
|
|
// The key fix: resolveReplySequence should work even when sessionId doesn't match
|
|
const resolved = await store.resolveReplySequence(false, senderUin, messageId);
|
|
expect(resolved).toBe(sequence);
|
|
|
|
// More importantly: it should also work when we pass a different sessionId
|
|
// (which was the bug - we were passing recipient UIN instead of sender UIN)
|
|
const differentUin = 999999;
|
|
const resolvedWithDifferentSession = await store.resolveReplySequence(
|
|
false,
|
|
differentUin,
|
|
messageId,
|
|
);
|
|
expect(resolvedWithDifferentSession).toBe(sequence);
|
|
});
|
|
|
|
it('uses the sender-local sequence for private replies, not the NT history sequence', async () => {
|
|
const messageId = -123456789;
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: 111111,
|
|
sequence: 63214,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 32743,
|
|
privateDirection: 'incoming',
|
|
random: 88,
|
|
timestamp: 1700000000,
|
|
});
|
|
|
|
expect(await store.resolveReplySequence(false, 111111, messageId)).toBe(32743);
|
|
});
|
|
|
|
it('returns null for non-existent message', async () => {
|
|
const resolved = await store.resolveReplySequence(true, 123456, 999999);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it('returns null for invalid messageId', async () => {
|
|
const resolved = await store.resolveReplySequence(true, 123456, 0);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it('returns null for invalid sessionId', async () => {
|
|
const messageId = hashMessageIdInt32(100, 123456, GROUP_MESSAGE_EVENT);
|
|
const resolved = await store.resolveReplySequence(true, 0, messageId);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it('distinguishes between group and private messages', async () => {
|
|
const sessionId = 123456;
|
|
const sequence = 300;
|
|
|
|
// Store a group message
|
|
const groupMessageId = hashMessageIdInt32(sequence, sessionId, GROUP_MESSAGE_EVENT);
|
|
await store.storeMeta(groupMessageId, {
|
|
isGroup: true,
|
|
targetId: sessionId,
|
|
sequence,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Store a private message with same sequence but different hash
|
|
const privateMessageId = hashMessageIdInt32(sequence, sessionId, PRIVATE_MESSAGE_EVENT);
|
|
await store.storeMeta(privateMessageId, {
|
|
isGroup: false,
|
|
targetId: sessionId,
|
|
sequence,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Should resolve correctly based on isGroup flag
|
|
const groupResolved = await store.resolveReplySequence(true, sessionId, groupMessageId);
|
|
expect(groupResolved).toBe(sequence);
|
|
|
|
const privateResolved = await store.resolveReplySequence(
|
|
false,
|
|
sessionId,
|
|
privateMessageId,
|
|
);
|
|
expect(privateResolved).toBe(sequence);
|
|
|
|
// Should not cross-resolve
|
|
const wrongGroupResolve = await store.resolveReplySequence(
|
|
true,
|
|
sessionId,
|
|
privateMessageId,
|
|
);
|
|
expect(wrongGroupResolve).toBeNull();
|
|
|
|
const wrongPrivateResolve = await store.resolveReplySequence(
|
|
false,
|
|
sessionId,
|
|
groupMessageId,
|
|
);
|
|
expect(wrongPrivateResolve).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('storeEvent and findEvent', () => {
|
|
it('stores and retrieves event data', async () => {
|
|
const messageId = 12345;
|
|
const event = {
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
message_id: messageId,
|
|
group_id: 123456,
|
|
message: 'test message',
|
|
time: Date.now(),
|
|
};
|
|
|
|
await store.storeEvent(messageId, true, 123456, 100, GROUP_MESSAGE_EVENT, event);
|
|
|
|
const retrieved = await store.findEvent(messageId);
|
|
expect(retrieved).toEqual(event);
|
|
});
|
|
|
|
it('returns null for non-existent event', async () => {
|
|
const retrieved = await store.findEvent(999999);
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('storeMeta and findMeta', () => {
|
|
it('stores and retrieves message meta', async () => {
|
|
const messageId = 54321;
|
|
const meta = {
|
|
isGroup: true,
|
|
targetId: 123456,
|
|
sequence: 100,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 1,
|
|
random: 12345,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
await store.storeMeta(messageId, meta);
|
|
|
|
const retrieved = await store.findMeta(messageId);
|
|
expect(retrieved).toEqual(meta);
|
|
});
|
|
|
|
it('returns null for non-existent meta', async () => {
|
|
const retrieved = await store.findMeta(999999);
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
|
|
it('rolls back the full metadata batch when a later write fails', async () => {
|
|
const firstMessageId = 54323;
|
|
const rejectedMessageId = 54324;
|
|
const database = new DatabaseSync(testDbPath);
|
|
database.exec(`
|
|
CREATE TRIGGER reject_second_message_meta
|
|
BEFORE INSERT ON messages
|
|
WHEN NEW.message_hash = ${rejectedMessageId}
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'second message meta rejected');
|
|
END
|
|
`);
|
|
database.close();
|
|
|
|
await expect(
|
|
async () =>
|
|
await store.storeMetas([
|
|
{
|
|
messageId: firstMessageId,
|
|
meta: {
|
|
isGroup: true,
|
|
targetId: 123456,
|
|
sequence: 101,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 1001,
|
|
timestamp: 1700000001,
|
|
},
|
|
},
|
|
{
|
|
messageId: rejectedMessageId,
|
|
meta: {
|
|
isGroup: true,
|
|
targetId: 123456,
|
|
sequence: 102,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 1002,
|
|
timestamp: 1700000002,
|
|
},
|
|
},
|
|
]),
|
|
).rejects.toThrow(/second message meta rejected/);
|
|
|
|
expect(await store.findMeta(firstMessageId)).toBeNull();
|
|
expect(await store.findMeta(rejectedMessageId)).toBeNull();
|
|
});
|
|
|
|
it('preserves the write error when SQLite already rolled back the transaction', async () => {
|
|
const rejectedMessageId = 54325;
|
|
const database = new DatabaseSync(testDbPath);
|
|
database.exec(`
|
|
CREATE TRIGGER force_metadata_transaction_rollback
|
|
BEFORE INSERT ON messages
|
|
WHEN NEW.message_hash = ${rejectedMessageId}
|
|
BEGIN
|
|
SELECT RAISE(ROLLBACK, 'metadata transaction forced rollback');
|
|
END
|
|
`);
|
|
database.close();
|
|
|
|
await expect(
|
|
async () =>
|
|
await store.storeMetas([
|
|
{
|
|
messageId: rejectedMessageId,
|
|
meta: {
|
|
isGroup: true,
|
|
targetId: 123456,
|
|
sequence: 103,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 1003,
|
|
timestamp: 1700000003,
|
|
},
|
|
},
|
|
]),
|
|
).rejects.toThrow(/metadata transaction forced rollback.*rollback also failed/);
|
|
|
|
expect(await store.findMeta(rejectedMessageId)).toBeNull();
|
|
});
|
|
|
|
it('does not promote a non-authoritative sequence when event data arrives later', async () => {
|
|
const messageId = 54322;
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: 123456,
|
|
sequence: 0,
|
|
sequenceAuthoritative: false,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 88,
|
|
random: 12,
|
|
timestamp: 1700000000,
|
|
});
|
|
await store.storeEvent(messageId, false, 123456, 88, PRIVATE_MESSAGE_EVENT, {
|
|
time: 1700000000,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
message_id: messageId,
|
|
message_seq: 88,
|
|
});
|
|
|
|
expect(await store.findMeta(messageId)).toMatchObject({
|
|
sequenceAuthoritative: false,
|
|
clientSequence: 88,
|
|
});
|
|
});
|
|
|
|
it('rolls back private history metadata when storing the event body fails', async () => {
|
|
const messageId = -987654321;
|
|
const peerId = 555;
|
|
const database = new DatabaseSync(testDbPath);
|
|
database.exec(`
|
|
CREATE TRIGGER reject_history_event_body
|
|
BEFORE UPDATE OF data ON messages
|
|
WHEN NEW.message_hash = ${messageId} AND NEW.data IS NOT NULL
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'history event body rejected');
|
|
END
|
|
`);
|
|
database.close();
|
|
|
|
await expect(
|
|
async () =>
|
|
await store.storeHistoryEvent(
|
|
messageId,
|
|
false,
|
|
peerId,
|
|
850,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
{
|
|
time: 1_700_000_000,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
user_id: peerId,
|
|
message_id: messageId,
|
|
message_seq: 50,
|
|
},
|
|
{
|
|
sequenceAuthoritative: true,
|
|
meta: {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 850,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 50,
|
|
privateDirection: 'incoming',
|
|
random: 42,
|
|
timestamp: 1_700_000_000,
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow(/history event body rejected/);
|
|
|
|
expect(await store.findMeta(messageId)).toBeNull();
|
|
expect(await store.findEvent(messageId)).toBeNull();
|
|
expect(await store.findLatestAuthoritativeSequence(false, peerId)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('listSessionEvents', () => {
|
|
async function storeGroupSequence(groupId: number): Promise<void> {
|
|
for (let sequence = 1; sequence <= 5; sequence++) {
|
|
await store.storeEvent(sequence, true, groupId, sequence, GROUP_MESSAGE_EVENT, {
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: sequence,
|
|
message_seq: sequence,
|
|
});
|
|
}
|
|
}
|
|
|
|
it('returns the anchor and newer messages in chronological order', async () => {
|
|
const groupId = 123456;
|
|
await storeGroupSequence(groupId);
|
|
|
|
const messages = await store.listSessionEvents(true, groupId, 3, 3, false);
|
|
|
|
expect(messages.map((message) => message.message_seq)).toEqual([3, 4, 5]);
|
|
});
|
|
|
|
it('keeps the anchor and older messages as the default', async () => {
|
|
const groupId = 123456;
|
|
await storeGroupSequence(groupId);
|
|
|
|
const messages = await store.listSessionEvents(true, groupId, 3, 3);
|
|
|
|
expect(messages.map((message) => message.message_seq)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('excludes unconfirmed sequences from replies and history anchors (#254)', async () => {
|
|
const groupId = 123456;
|
|
await store.storeEvent(1, true, groupId, 9748, GROUP_MESSAGE_EVENT, {
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
group_name: 'Test group',
|
|
message_id: 1,
|
|
message_seq: 9748,
|
|
});
|
|
await store.storeEvent(
|
|
2,
|
|
true,
|
|
groupId,
|
|
1799283572,
|
|
GROUP_MESSAGE_EVENT,
|
|
{
|
|
post_type: 'message_sent',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 2,
|
|
message_seq: 1799283572,
|
|
message: [{ type: 'file', data: { file: 'test.bin' } }],
|
|
},
|
|
{ sequenceAuthoritative: false },
|
|
);
|
|
|
|
expect(await store.findLatestAuthoritativeSequence(true, groupId)).toBe(9748);
|
|
expect(
|
|
(await store.listSessionEvents(true, groupId)).map(
|
|
(message) => message.message_seq,
|
|
),
|
|
).toEqual([9748]);
|
|
expect(await store.resolveReplySequence(true, groupId, 2)).toBeNull();
|
|
expect(await store.findMeta(2)).toMatchObject({
|
|
sequence: 1799283572,
|
|
sequenceAuthoritative: false,
|
|
});
|
|
});
|
|
|
|
it('migrates legacy synthetic group-file and reply-backfill sequences (#254)', async () => {
|
|
const groupId = 123456;
|
|
await store.close();
|
|
|
|
const legacy = new DatabaseSync(testDbPath);
|
|
legacy.exec(`
|
|
DROP TABLE messages;
|
|
CREATE TABLE messages (
|
|
message_hash INTEGER PRIMARY KEY,
|
|
is_group INTEGER NOT NULL,
|
|
session_id INTEGER NOT NULL,
|
|
sequence INTEGER NOT NULL,
|
|
event_name TEXT NOT NULL,
|
|
client_sequence INTEGER NOT NULL DEFAULT 0,
|
|
random INTEGER NOT NULL DEFAULT 0,
|
|
timestamp INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT
|
|
)
|
|
`);
|
|
const insert = legacy.prepare(`
|
|
INSERT INTO messages
|
|
(message_hash, is_group, session_id, sequence, event_name, client_sequence, random, timestamp, data)
|
|
VALUES (?, 1, ?, ?, ?, 0, ?, ?, ?)
|
|
`);
|
|
const insertPrivate = legacy.prepare(`
|
|
INSERT INTO messages
|
|
(message_hash, is_group, session_id, sequence, event_name, client_sequence, random, timestamp, data)
|
|
VALUES (?, 0, ?, ?, ?, 0, ?, ?, ?)
|
|
`);
|
|
insert.run(
|
|
1,
|
|
groupId,
|
|
9748,
|
|
GROUP_MESSAGE_EVENT,
|
|
0,
|
|
1700000000,
|
|
JSON.stringify({
|
|
time: 1700000000,
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 1,
|
|
message_seq: 9748,
|
|
user_id: 222,
|
|
message: [{ type: 'text', data: { text: 'real' } }],
|
|
}),
|
|
);
|
|
insert.run(
|
|
2,
|
|
groupId,
|
|
1799283572,
|
|
GROUP_MESSAGE_EVENT,
|
|
1799283572,
|
|
1700000100,
|
|
JSON.stringify({
|
|
time: 1700000100,
|
|
post_type: 'message_sent',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 2,
|
|
message_seq: 1799283572,
|
|
user_id: 10001,
|
|
message: [{ type: 'file', data: { file: 'test.bin' } }],
|
|
}),
|
|
);
|
|
insert.run(
|
|
3,
|
|
groupId,
|
|
1813530543,
|
|
GROUP_MESSAGE_EVENT,
|
|
0,
|
|
1700000200,
|
|
JSON.stringify({
|
|
time: 1700000200,
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 3,
|
|
message_seq: 1813530543,
|
|
user_id: 0,
|
|
message: [{ type: 'text', data: { text: '[引用消息]' } }],
|
|
}),
|
|
);
|
|
insert.run(
|
|
4,
|
|
groupId,
|
|
9732,
|
|
GROUP_MESSAGE_EVENT,
|
|
0,
|
|
0,
|
|
JSON.stringify({
|
|
time: 0,
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 4,
|
|
message_seq: 9732,
|
|
user_id: 0,
|
|
message: [{ type: 'text', data: { text: '[已删除]' } }],
|
|
}),
|
|
);
|
|
insert.run(
|
|
5,
|
|
groupId,
|
|
1813530544,
|
|
GROUP_MESSAGE_EVENT,
|
|
0,
|
|
1700000300,
|
|
JSON.stringify({
|
|
time: 1700000300,
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 5,
|
|
message_seq: 1813530544,
|
|
user_id: 800,
|
|
message: [{ type: 'text', data: { text: 'quoted inline content' } }],
|
|
sender: {
|
|
user_id: 800,
|
|
nickname: '',
|
|
card: '',
|
|
role: 'member',
|
|
sex: 'unknown',
|
|
age: 0,
|
|
},
|
|
anonymous: null,
|
|
}),
|
|
);
|
|
insertPrivate.run(
|
|
6,
|
|
555,
|
|
800,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
1234,
|
|
1700000400,
|
|
JSON.stringify({
|
|
time: 1700000400,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: 6,
|
|
message_seq: 800,
|
|
user_id: 555,
|
|
message: [{ type: 'text', data: { text: 'real private message' } }],
|
|
sender: { user_id: 555, nickname: 'Alice', sex: 'unknown', age: 0 },
|
|
}),
|
|
);
|
|
insertPrivate.run(
|
|
7,
|
|
555,
|
|
1813530545,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
0,
|
|
1700000500,
|
|
JSON.stringify({
|
|
time: 1700000500,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: 7,
|
|
message_seq: 1813530545,
|
|
user_id: 555,
|
|
message: [{ type: 'text', data: { text: 'quoted private content' } }],
|
|
sender: { user_id: 555, nickname: '', sex: 'unknown', age: 0 },
|
|
}),
|
|
);
|
|
legacy
|
|
.prepare(
|
|
`
|
|
INSERT INTO messages
|
|
(message_hash, is_group, session_id, sequence, event_name, client_sequence, random, timestamp, data)
|
|
VALUES (9, 0, 555, 850, ?, 4455, 99, 1700000550, NULL)
|
|
`,
|
|
)
|
|
.run(PRIVATE_MESSAGE_EVENT);
|
|
legacy.close();
|
|
|
|
store = new MessageStore(testDbPath);
|
|
await store.initialization;
|
|
const migrator = new MessageStoreMigrator(testDbPath);
|
|
await migrator.initialization;
|
|
|
|
expect(await migrator.getStatus()).toMatchObject({
|
|
phase: 'migrating',
|
|
processed: 0,
|
|
total: 8,
|
|
});
|
|
expect(await store.findMeta(2)).toMatchObject({ sequenceAuthoritative: true });
|
|
|
|
expect(await migrator.runBatch(3)).toMatchObject({
|
|
phase: 'migrating',
|
|
processed: 3,
|
|
total: 8,
|
|
});
|
|
await store.storeMeta(-100, {
|
|
isGroup: true,
|
|
targetId: groupId,
|
|
sequence: 9700,
|
|
sequenceAuthoritative: true,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: 1700000600,
|
|
});
|
|
await migrator.close();
|
|
|
|
const resumedMigrator = new MessageStoreMigrator(testDbPath);
|
|
await resumedMigrator.initialization;
|
|
expect(await resumedMigrator.getStatus()).toMatchObject({
|
|
phase: 'migrating',
|
|
processed: 3,
|
|
total: 8,
|
|
});
|
|
expect(await resumedMigrator.runBatch(3)).toMatchObject({
|
|
phase: 'migrating',
|
|
processed: 6,
|
|
total: 8,
|
|
});
|
|
expect(await resumedMigrator.runBatch(3)).toMatchObject({
|
|
phase: 'complete',
|
|
processed: 8,
|
|
total: 8,
|
|
});
|
|
await resumedMigrator.close();
|
|
|
|
expect(await store.findLatestAuthoritativeSequence(true, groupId)).toBe(9748);
|
|
expect(
|
|
(await store.listSessionEvents(true, groupId)).map(
|
|
(message) => message.message_seq,
|
|
),
|
|
).toEqual([9748]);
|
|
expect(await store.findMeta(2)).toMatchObject({ sequenceAuthoritative: false });
|
|
expect(await store.findMeta(3)).toMatchObject({ sequenceAuthoritative: false });
|
|
expect(await store.findMeta(4)).toMatchObject({ sequenceAuthoritative: false });
|
|
expect(await store.findMeta(5)).toMatchObject({ sequenceAuthoritative: false });
|
|
// Pre-fix observed/fetched private rows contain sender-local field-5
|
|
// sequences and may not become anchors. A send receipt with a retained
|
|
// client sequence is already authoritative and remains usable.
|
|
expect(await store.findLatestAuthoritativeSequence(false, 555)).toBe(850);
|
|
expect(await store.findMeta(6)).toMatchObject({ sequenceAuthoritative: false });
|
|
expect(await store.findMeta(7)).toMatchObject({ sequenceAuthoritative: false });
|
|
expect(await store.findMeta(9)).toMatchObject({ sequenceAuthoritative: true });
|
|
expect(await store.findMeta(-100)).toMatchObject({
|
|
sequence: 9700,
|
|
sequenceAuthoritative: true,
|
|
});
|
|
|
|
await store.storeMeta(8, {
|
|
isGroup: false,
|
|
targetId: 555,
|
|
sequence: 900,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 1240,
|
|
random: 88,
|
|
timestamp: 1700000600,
|
|
});
|
|
expect(await store.findLatestAuthoritativeSequence(false, 555)).toBe(900);
|
|
});
|
|
|
|
it('resumes classification when an older schema upgrade stopped between steps', async () => {
|
|
await store.close();
|
|
const legacy = new DatabaseSync(testDbPath);
|
|
legacy.exec(`
|
|
DROP TABLE messages;
|
|
CREATE TABLE messages (
|
|
message_hash INTEGER PRIMARY KEY,
|
|
is_group INTEGER NOT NULL,
|
|
session_id INTEGER NOT NULL,
|
|
sequence INTEGER NOT NULL,
|
|
sequence_authoritative INTEGER NOT NULL DEFAULT 1,
|
|
event_name TEXT NOT NULL,
|
|
client_sequence INTEGER NOT NULL DEFAULT 0,
|
|
private_direction INTEGER NOT NULL DEFAULT -1,
|
|
random INTEGER NOT NULL DEFAULT 0,
|
|
timestamp INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT
|
|
);
|
|
CREATE TABLE message_store_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at INTEGER NOT NULL
|
|
);
|
|
INSERT INTO messages
|
|
(message_hash, is_group, session_id, sequence, sequence_authoritative,
|
|
event_name, client_sequence, private_direction, random, timestamp, data)
|
|
VALUES
|
|
(1, 0, 555, 800, 1, '${PRIVATE_MESSAGE_EVENT}', 0, 0, 0, 1700000000,
|
|
'{"post_type":"message","message_type":"private","sub_type":"friend"}')
|
|
`);
|
|
legacy.close();
|
|
|
|
store = new MessageStore(testDbPath);
|
|
await store.initialization;
|
|
const migrator = new MessageStoreMigrator(testDbPath);
|
|
await migrator.initialization;
|
|
expect(await migrator.getStatus()).toEqual({
|
|
phase: 'migrating',
|
|
processed: 0,
|
|
total: 1,
|
|
});
|
|
expect(await migrator.runBatch(10)).toEqual({
|
|
phase: 'complete',
|
|
processed: 1,
|
|
total: 1,
|
|
});
|
|
await migrator.close();
|
|
expect(await store.findMeta(1)).toMatchObject({ sequenceAuthoritative: false });
|
|
});
|
|
|
|
it('creates the normal indexes when background preparation creates the database', async () => {
|
|
await store.close();
|
|
fs.unlinkSync(testDbPath);
|
|
|
|
await prepareMessageStoreDatabase(testDbPath);
|
|
const database = new DatabaseSync(testDbPath);
|
|
const indexes = database
|
|
.prepare(
|
|
`
|
|
SELECT name FROM sqlite_schema
|
|
WHERE type = 'index' AND tbl_name = 'messages'
|
|
`,
|
|
)
|
|
.all() as Array<{ name: string }>;
|
|
database.close();
|
|
|
|
expect(indexes.map(({ name }) => name).sort()).toEqual([
|
|
'idx_messages_private_client_direction',
|
|
'idx_messages_private_client_seq',
|
|
'idx_messages_session_authoritative_seq',
|
|
'idx_messages_session_seq',
|
|
]);
|
|
store = new MessageStore(testDbPath);
|
|
await store.initialization;
|
|
});
|
|
});
|
|
|
|
describe('private recall invalidation', () => {
|
|
it('removes the recalled cached event by peer and client sequence', async () => {
|
|
const messageId = -123456789;
|
|
const peerId = 555;
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 63214,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 32743,
|
|
random: 88,
|
|
timestamp: 1700000600,
|
|
});
|
|
await store.storeEvent(messageId, false, peerId, 63214, PRIVATE_MESSAGE_EVENT, {
|
|
time: 1700000600,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: messageId,
|
|
message_seq: 63214,
|
|
user_id: peerId,
|
|
message: [{ type: 'text', data: { text: '77' } }],
|
|
});
|
|
const selfMessageId = -22334455;
|
|
await store.storeMeta(selfMessageId, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 73214,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 32743,
|
|
random: 99,
|
|
timestamp: 1700000601,
|
|
});
|
|
await store.storeEvent(selfMessageId, false, peerId, 73214, PRIVATE_MESSAGE_EVENT, {
|
|
time: 1700000601,
|
|
post_type: 'message_sent',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: selfMessageId,
|
|
message_seq: 32743,
|
|
user_id: 10001,
|
|
target_id: peerId,
|
|
message: [{ type: 'text', data: { text: 'self' } }],
|
|
});
|
|
|
|
expect(await store.recordPrivateRecall(peerId, 32743, false, 1700000700)).toBe(
|
|
messageId,
|
|
);
|
|
expect(await store.findEvent(messageId)).toBeNull();
|
|
expect(await store.findMeta(messageId)).toBeNull();
|
|
expect(await store.findEvent(selfMessageId)).not.toBeNull();
|
|
expect(await store.recordPrivateRecall(peerId, 32743, false, 1700000700)).toBeNull();
|
|
expect(await store.recordPrivateRecall(peerId, 32743, true, 1700000700)).toBe(
|
|
selfMessageId,
|
|
);
|
|
});
|
|
|
|
it('prevents a recalled message from being stored after a meta-only race', async () => {
|
|
const messageId = -123456789;
|
|
const peerId = 555;
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 63214,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 32743,
|
|
privateDirection: 'incoming',
|
|
random: 88,
|
|
timestamp: 1700000600,
|
|
});
|
|
|
|
expect(await store.recordPrivateRecall(peerId, 32743, false, 1700000700)).toBe(
|
|
messageId,
|
|
);
|
|
await store.storeEvent(messageId, false, peerId, 63214, PRIVATE_MESSAGE_EVENT, {
|
|
time: 1700000600,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: messageId,
|
|
message_seq: 32743,
|
|
user_id: peerId,
|
|
});
|
|
|
|
expect(await store.findEvent(messageId)).toBeNull();
|
|
expect(await store.findMeta(messageId)).toBeNull();
|
|
});
|
|
|
|
it('prevents an outgoing self-sent event from reviving after its meta is recalled', async () => {
|
|
const messageId = -22334455;
|
|
const peerId = 555;
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 73214,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 32743,
|
|
privateDirection: 'outgoing',
|
|
random: 99,
|
|
timestamp: 1700000600,
|
|
});
|
|
|
|
expect(await store.recordPrivateRecall(peerId, 32743, true, 1700000700)).toBe(
|
|
messageId,
|
|
);
|
|
await store.storeEvent(messageId, false, peerId, 73214, PRIVATE_MESSAGE_EVENT, {
|
|
time: 1700000600,
|
|
post_type: 'message_sent',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: messageId,
|
|
message_seq: 32743,
|
|
user_id: 10001,
|
|
target_id: peerId,
|
|
});
|
|
|
|
expect(await store.findEvent(messageId)).toBeNull();
|
|
expect(await store.findMeta(messageId)).toBeNull();
|
|
});
|
|
|
|
it('does not delete a later message that reused the recalled client sequence', async () => {
|
|
const peerId = 555;
|
|
const clientSequence = 32743;
|
|
const oldMessageId = -123456789;
|
|
const futureMessageId = -22334455;
|
|
|
|
const storePrivate = async (
|
|
messageId: number,
|
|
ntSequence: number,
|
|
timestamp: number,
|
|
) => {
|
|
await store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: ntSequence,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence,
|
|
privateDirection: 'incoming',
|
|
random: ntSequence,
|
|
timestamp,
|
|
});
|
|
await store.storeEvent(
|
|
messageId,
|
|
false,
|
|
peerId,
|
|
ntSequence,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
{
|
|
time: timestamp,
|
|
post_type: 'message',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: messageId,
|
|
message_seq: clientSequence,
|
|
user_id: peerId,
|
|
},
|
|
);
|
|
};
|
|
|
|
await storePrivate(oldMessageId, 63214, 600);
|
|
await storePrivate(futureMessageId, 73214, 800);
|
|
|
|
expect(await store.findPrivateMessageId(peerId, clientSequence, false, 700)).toBe(
|
|
oldMessageId,
|
|
);
|
|
expect(await store.recordPrivateRecall(peerId, clientSequence, false, 700)).toBe(
|
|
oldMessageId,
|
|
);
|
|
expect(await store.findEvent(oldMessageId)).toBeNull();
|
|
expect(await store.findEvent(futureMessageId)).not.toBeNull();
|
|
expect(await store.findPrivateMessageId(peerId, clientSequence, false)).toBe(
|
|
futureMessageId,
|
|
);
|
|
});
|
|
|
|
it('keeps the opposite direction when sender-local sequences collide', async () => {
|
|
const peerId = 555;
|
|
await store.recordPrivateRecall(peerId, 32743, false, 1700000700);
|
|
await store.storeEvent(-22334455, false, peerId, 73214, PRIVATE_MESSAGE_EVENT, {
|
|
time: 1700000601,
|
|
post_type: 'message_sent',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message_id: -22334455,
|
|
message_seq: 32743,
|
|
user_id: 10001,
|
|
target_id: peerId,
|
|
});
|
|
|
|
expect(await store.findEvent(-22334455)).not.toBeNull();
|
|
});
|
|
|
|
it('rejects invalid recall identifiers instead of treating them as cache misses', async () => {
|
|
await expect(
|
|
async () => await store.recordPrivateRecall(0, 32743, false, 1700000700),
|
|
).rejects.toThrow('invalid private recall peer');
|
|
await expect(
|
|
async () => await store.recordPrivateRecall(555, 0, false, 1700000700),
|
|
).rejects.toThrow('invalid private recall client sequence');
|
|
});
|
|
});
|
|
|
|
describe('resolvePrivateReplyMessageId', () => {
|
|
const peerId = 2705892349;
|
|
const sendId = -12721351;
|
|
const sentAt = 1_788_102_428;
|
|
|
|
async function storeOutgoing(
|
|
clientSequence: number,
|
|
direction: 'incoming' | 'outgoing' = 'outgoing',
|
|
) {
|
|
await store.storeMeta(sendId, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 682,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence,
|
|
privateDirection: direction,
|
|
random: 1,
|
|
timestamp: sentAt,
|
|
});
|
|
}
|
|
|
|
it('returns the send id when the quote sequence matches the stored client sequence', async () => {
|
|
await storeOutgoing(27_892);
|
|
expect(await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt)).toBe(
|
|
sendId,
|
|
);
|
|
});
|
|
|
|
it('returns the send id by timestamp when the quote sequence is not the local client sequence', async () => {
|
|
await storeOutgoing(201_616_628);
|
|
expect(await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt)).toBe(
|
|
sendId,
|
|
);
|
|
});
|
|
|
|
it('does not use timestamp fallback for incoming quotes', async () => {
|
|
await storeOutgoing(201_616_628, 'incoming');
|
|
expect(
|
|
await store.resolvePrivateReplyMessageId(peerId, 27_892, false, sentAt),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns the send id when the quote sequence matches the stored conversation sequence', async () => {
|
|
await storeOutgoing(201_616_628);
|
|
expect(await store.resolvePrivateReplyMessageId(peerId, 682, true, sentAt + 2)).toBe(
|
|
sendId,
|
|
);
|
|
});
|
|
|
|
it('returns the send id when the quote time drifts a few seconds from the send receipt', async () => {
|
|
await storeOutgoing(201_616_628);
|
|
expect(await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt + 2)).toBe(
|
|
sendId,
|
|
);
|
|
});
|
|
|
|
it('does not guess by time when two outgoing messages share the quote window', async () => {
|
|
await storeOutgoing(201_616_628);
|
|
await store.storeMeta(sendId + 1, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: 683,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 201_616_629,
|
|
privateDirection: 'outgoing',
|
|
random: 2,
|
|
timestamp: sentAt + 1,
|
|
});
|
|
expect(
|
|
await store.resolvePrivateReplyMessageId(peerId, 27_892, true, sentAt + 2),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('does not choose the latest same-second message without unique quoted content', async () => {
|
|
for (const id of [101, 102]) {
|
|
await store.storeMeta(id, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: id,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: id + 1000,
|
|
privateDirection: 'outgoing',
|
|
random: id,
|
|
timestamp: sentAt,
|
|
});
|
|
await store.storeEvent(id, false, peerId, id, PRIVATE_MESSAGE_EVENT, {
|
|
time: sentAt,
|
|
post_type: 'message_sent',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message: [{ type: 'text', data: { text: 'same text' } }],
|
|
});
|
|
}
|
|
|
|
expect(await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt)).toBeNull();
|
|
expect(
|
|
await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt, [
|
|
{ type: 'text', text: 'same text' },
|
|
]),
|
|
).toBeNull();
|
|
expect(
|
|
await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt, [
|
|
{ type: 'text', text: 'same' },
|
|
]),
|
|
).toBeNull();
|
|
expect(
|
|
await store.resolvePrivateReplyMessageId(peerId, 1101, true, sentAt, [
|
|
{ type: 'text', text: 'same text' },
|
|
]),
|
|
).toBe(101);
|
|
});
|
|
|
|
it.each([
|
|
['truncated text', 'short', { type: 'text', data: { text: 'short but longer' } }],
|
|
['media label', '[语音]', { type: 'record', data: { file: 'voice.amr' } }],
|
|
])(
|
|
'does not confuse plain text with a competing %s preview',
|
|
async (_name, text, other) => {
|
|
for (const [id, message] of [
|
|
[101, [{ type: 'text', data: { text } }]],
|
|
[102, [other]],
|
|
] as const) {
|
|
await store.storeMeta(id, {
|
|
isGroup: false,
|
|
targetId: peerId,
|
|
sequence: id,
|
|
sequenceAuthoritative: true,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: id + 1000,
|
|
privateDirection: 'outgoing',
|
|
random: id,
|
|
timestamp: sentAt,
|
|
});
|
|
await store.storeEvent(id, false, peerId, id, PRIVATE_MESSAGE_EVENT, {
|
|
time: sentAt,
|
|
post_type: 'message_sent',
|
|
message_type: 'private',
|
|
sub_type: 'friend',
|
|
message: JSON.parse(JSON.stringify(message)),
|
|
});
|
|
}
|
|
|
|
expect(
|
|
await store.resolvePrivateReplyMessageId(peerId, 999, true, sentAt, [
|
|
{ type: 'text', text },
|
|
]),
|
|
).toBeNull();
|
|
},
|
|
);
|
|
});
|
|
|
|
it('lets a real group event replace an older non-authoritative placeholder', async () => {
|
|
const groupId = 123456;
|
|
await store.storeEvent(
|
|
123,
|
|
true,
|
|
groupId,
|
|
99,
|
|
GROUP_MESSAGE_EVENT,
|
|
{
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 123,
|
|
message_seq: 99,
|
|
},
|
|
{ sequenceAuthoritative: false },
|
|
);
|
|
await store.storeEvent(123, true, groupId, 100, GROUP_MESSAGE_EVENT, {
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: groupId,
|
|
message_id: 123,
|
|
message_seq: 100,
|
|
});
|
|
|
|
expect(await store.findMeta(123)).toMatchObject({
|
|
sequence: 100,
|
|
sequenceAuthoritative: true,
|
|
});
|
|
});
|
|
|
|
describe('listReadSessions', () => {
|
|
it('uses current groups and only genuine incoming friend sessions', async () => {
|
|
const event = (postType: string, subType: string) => ({
|
|
time: 1700000000,
|
|
post_type: postType,
|
|
message_type: 'private',
|
|
sub_type: subType,
|
|
});
|
|
await store.storeEvent(
|
|
1001,
|
|
false,
|
|
40001,
|
|
1,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
event('message', 'friend'),
|
|
);
|
|
await store.storeEvent(
|
|
1002,
|
|
false,
|
|
40001,
|
|
2,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
event('message', 'friend'),
|
|
);
|
|
await store.storeEvent(
|
|
1003,
|
|
false,
|
|
40002,
|
|
3,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
event('message', 'group'),
|
|
);
|
|
await store.storeEvent(
|
|
1004,
|
|
false,
|
|
40003,
|
|
4,
|
|
PRIVATE_MESSAGE_EVENT,
|
|
event('message_sent', 'friend'),
|
|
);
|
|
|
|
expect(await store.listReadSessions([30002, 30001, 30002])).toEqual({
|
|
groupIds: [30002, 30001],
|
|
privateUserIds: [40001],
|
|
});
|
|
});
|
|
|
|
it('fails on a corrupt current group target', async () => {
|
|
await expect(async () => await store.listReadSessions([0])).rejects.toThrow(
|
|
'invalid group id 0',
|
|
);
|
|
});
|
|
});
|
|
});
|