Files
SnowLuma/packages/onebot/tests/message-store-migration.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

229 lines
9.5 KiB
TypeScript

import { SqliteDatabase } from '@snowluma/sqlite';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageStoreMigrator, prepareMessageStoreDatabase } from '../src/message-store-migration';
const pendingIndex = 'idx_messages_pending_classification_v1';
describe('message history migration scans', () => {
let directory: string;
let dbPath: string;
let db: SqliteDatabase;
let migrator: MessageStoreMigrator | undefined;
beforeEach(async () => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-migration-scans-'));
dbPath = path.join(directory, 'messages.db');
await prepareMessageStoreDatabase(dbPath);
db = new SqliteDatabase(dbPath);
const insert = db.prepare(`INSERT INTO messages
(message_hash, is_group, session_id, sequence, client_sequence, event_name, data, media_data, classification_version)
VALUES (?, 0, 42, 1, 1, 'message.private', ?, ?, ?)`);
const data = JSON.stringify({
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message: [{ type: 'text', data: { text: 'x'.repeat(3000) } }],
});
await db.exec('BEGIN');
for (let hash = -500; hash <= 500; hash++) {
await insert.run(
hash,
data,
'{"retained":"snapshot"}',
[-400, 0, 400].includes(hash) ? 0 : 1,
);
}
await db.exec('COMMIT');
});
afterEach(async () => {
vi.restoreAllMocks();
await migrator?.close();
migrator = undefined;
await db.close();
fs.rmSync(directory, { recursive: true, force: true });
});
function capturedPendingQueries(queries: string[]): string[] {
return [
...new Set(
queries.filter((sql) =>
/SELECT[\s\S]+FROM messages\s+WHERE classification_version < \?/.test(sql),
),
),
];
}
async function expectPendingQueryPlans(queries: string[]): Promise<void> {
expect(queries).toHaveLength(3);
const planDb = new SqliteDatabase(dbPath, { readOnly: true });
try {
for (const sql of queries) {
const args = sql.includes('LIMIT')
? sql.includes('message_hash >')
? [1, -400, 1]
: [1, 1]
: [1];
const details = (await planDb.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args))
.map((row) => String(row.detail))
.join('\n');
expect(details).toContain(pendingIndex);
expect(details).not.toContain('USE TEMP B-TREE');
if (sql.includes('COUNT(*)')) {
expect(details).toContain('COVERING INDEX');
}
}
} finally {
await planDb.close();
}
}
it('uses pending rows for the actual count and ordered batch queries without changing history', async () => {
const before = await db
.prepare('SELECT message_hash, data, media_data FROM messages ORDER BY message_hash')
.all();
const prepare = vi.spyOn(SqliteDatabase.prototype, 'prepare');
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
const queries = capturedPendingQueries(prepare.mock.calls.map(([sql]) => sql));
await expectPendingQueryPlans(queries);
expect(await migrator.getStatus()).toEqual({ phase: 'migrating', processed: 0, total: 3 });
expect(await migrator.runBatch(2)).toEqual({ phase: 'migrating', processed: 2, total: 3 });
expect(await migrator.runBatch(2)).toEqual({ phase: 'complete', processed: 3, total: 3 });
await expectPendingQueryPlans(queries);
expect(
await db
.prepare('SELECT COUNT(*) AS n FROM messages WHERE classification_version < 1')
.get(),
).toEqual({ n: 0 });
expect(
await db
.prepare(
'SELECT message_hash, data, media_data FROM messages ORDER BY message_hash',
)
.all(),
).toEqual(before);
expect(
await db
.prepare(
'SELECT message_hash, private_direction FROM messages WHERE message_hash IN (-400, 0, 400) ORDER BY message_hash',
)
.all(),
).toEqual([
{ message_hash: -400, private_direction: 0 },
{ message_hash: 0, private_direction: 0 },
{ message_hash: 400, private_direction: 0 },
]);
});
it('adds the index when resuming a migration started by an older version', async () => {
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
expect(await migrator.runBatch(1)).toEqual({ phase: 'migrating', processed: 1, total: 3 });
await migrator.close();
migrator = undefined;
await db.exec(`DROP INDEX IF EXISTS ${pendingIndex}`);
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
expect(
await db
.prepare('SELECT name FROM sqlite_schema WHERE type = ? AND name = ?')
.get('index', pendingIndex),
).toEqual({ name: pendingIndex });
expect(await migrator.getStatus()).toEqual({ phase: 'migrating', processed: 1, total: 3 });
expect(await migrator.runBatch(3)).toEqual({ phase: 'complete', processed: 3, total: 3 });
});
it('does not rebuild an existing pending index when an interrupted migration resumes', async () => {
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
await migrator.runBatch(1);
await migrator.close();
migrator = undefined;
const schemaVersion = await db.prepare('PRAGMA schema_version').get();
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
expect(await db.prepare('PRAGMA schema_version').get()).toEqual(schemaVersion);
expect(await migrator.runBatch(3)).toEqual({ phase: 'complete', processed: 3, total: 3 });
});
it('revisits rows left behind the cursor after a concurrent update prevented classification', async () => {
await db.exec(`CREATE TRIGGER defer_first BEFORE UPDATE ON messages
WHEN OLD.message_hash = -400 BEGIN SELECT RAISE(IGNORE); END`);
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
expect(await migrator.runBatch(1)).toEqual({ phase: 'migrating', processed: 0, total: 3 });
await db.exec('DROP TRIGGER defer_first');
expect(await migrator.runBatch(2)).toEqual({ phase: 'migrating', processed: 2, total: 3 });
expect(await migrator.runBatch(2)).toEqual({ phase: 'complete', processed: 3, total: 3 });
expect(
await db
.prepare('SELECT COUNT(*) AS n FROM messages WHERE classification_version < 1')
.get(),
).toEqual({ n: 0 });
expect(await db.prepare('SELECT COUNT(*) AS n FROM messages').get()).toEqual({ n: 1001 });
});
it('skips index creation and pending counts when an older version already completed migration', async () => {
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
await migrator.runBatch(10);
await migrator.close();
migrator = undefined;
await db.exec(`DROP INDEX IF EXISTS ${pendingIndex}`);
const exec = vi.spyOn(SqliteDatabase.prototype, 'exec');
const prepare = vi.spyOn(SqliteDatabase.prototype, 'prepare');
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
expect(await migrator.runBatch(10)).toEqual({ phase: 'complete', processed: 3, total: 3 });
expect(exec.mock.calls.some(([sql]) => /CREATE\s+INDEX/i.test(sql))).toBe(false);
expect(prepare.mock.calls.some(([sql]) => sql.includes('COUNT(*)'))).toBe(false);
expect(
await db
.prepare('SELECT name FROM sqlite_schema WHERE type = ? AND name = ?')
.get('index', pendingIndex),
).toBeUndefined();
});
it('propagates preparation failure and closes the failed migration connection', async () => {
const originalExec = SqliteDatabase.prototype.exec;
const failure = new Error('pending index unavailable');
let failedDb: SqliteDatabase | undefined;
vi.spyOn(SqliteDatabase.prototype, 'exec').mockImplementation(async function (
this: SqliteDatabase,
sql,
) {
if (sql.includes(pendingIndex)) {
failedDb = this;
throw failure;
}
return originalExec.call(this, sql);
});
await expect(async () => {
migrator = new MessageStoreMigrator(dbPath);
await migrator.initialization;
}).rejects.toThrow(failure);
expect(failedDb).toBeDefined();
await migrator!.close();
migrator = undefined;
await expect(failedDb!.exec('SELECT 1')).rejects.toThrow(/closing/);
expect(
await db
.prepare('SELECT COUNT(*) AS n FROM messages WHERE classification_version < 1')
.get(),
).toEqual({ n: 3 });
});
});