Files
SnowLuma/packages/onebot/tests/message-store-migration-worker.test.ts
T
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

479 lines
17 KiB
TypeScript

import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { MessagePort } from 'node:worker_threads';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
inspectMessageStoreMigration,
MessageStoreMigrator,
prepareMessageStoreDatabase,
type MessageStoreMigrationPreparation,
} from '../src/message-store-migration';
import {
isMessageStoreMigrationWorkerData,
MESSAGE_STORE_WORKER_KIND,
runMessageStoreMigrationWorker,
type MessageStoreMigrationWorkerData,
type MessageStoreMigrationWorkerMessage,
} from '../src/message-store-migration-worker';
function workerPayload(dbPath: string): MessageStoreMigrationWorkerData {
return { kind: 'snowluma-message-store-migration', dbPath };
}
function createControlPort(): {
port: MessagePort;
messages: MessageStoreMigrationWorkerMessage[];
preparations: MessageStoreMigrationPreparation[];
send(message: unknown): void;
listenerCount(): number;
waitForMessages(count: number): Promise<void>;
} {
const messages: MessageStoreMigrationWorkerMessage[] = [];
const preparations: MessageStoreMigrationPreparation[] = [];
const events = new EventEmitter();
const waiters: Array<() => void> = [];
return {
port: {
on: events.on.bind(events),
off: events.off.bind(events),
postMessage(message: MessageStoreMigrationWorkerMessage) {
if (message.kind === 'preparation') {
preparations.push(message.progress);
} else {
messages.push(message);
}
for (const check of [...waiters]) {
check();
}
},
} as unknown as MessagePort,
messages,
preparations,
send(message: unknown) {
events.emit('message', message);
},
waitForMessages(count) {
return new Promise<void>((resolve) => {
const check = () => {
if (messages.length < count) {
return;
}
const index = waiters.indexOf(check);
if (index >= 0) {
waiters.splice(index, 1);
}
resolve();
};
waiters.push(check);
check();
});
},
listenerCount() {
return events.listenerCount('message');
},
};
}
async function seedUnclassified(dbPath: string, count: number): Promise<void> {
await prepareMessageStoreDatabase(dbPath);
const db = new DatabaseSync(dbPath);
const insert = db.prepare(`
INSERT INTO messages (
message_hash, is_group, session_id, sequence, event_name, classification_version
) VALUES (?, 1, 70001, ?, 'message.group', 0)
`);
db.exec('BEGIN');
for (let hash = 1; hash <= count; hash += 1) {
insert.run(hash, hash);
}
db.exec('COMMIT');
db.close();
}
describe('isMessageStoreMigrationWorkerData', () => {
it('exports the worker kind used by the type guard', () => {
expect(MESSAGE_STORE_WORKER_KIND).toBe('snowluma-message-store-migration');
});
it('accepts only the worker kind with a string dbPath', () => {
expect(
isMessageStoreMigrationWorkerData({
kind: 'snowluma-message-store-migration',
dbPath: '/data/10001/messages.db',
}),
).toBe(true);
expect(
isMessageStoreMigrationWorkerData({
kind: 'snowluma-message-store-migration',
dbPath: '',
}),
).toBe(true);
expect(isMessageStoreMigrationWorkerData(null)).toBe(false);
expect(isMessageStoreMigrationWorkerData(undefined)).toBe(false);
expect(isMessageStoreMigrationWorkerData(1)).toBe(false);
expect(isMessageStoreMigrationWorkerData('snowluma-message-store-migration')).toBe(false);
expect(
isMessageStoreMigrationWorkerData({
kind: 'other-worker',
dbPath: '/data/10001/messages.db',
}),
).toBe(false);
expect(
isMessageStoreMigrationWorkerData({
kind: 'snowluma-message-store-migration',
}),
).toBe(false);
expect(
isMessageStoreMigrationWorkerData({
kind: 'snowluma-message-store-migration',
dbPath: 1,
}),
).toBe(false);
});
it('defaults to workerData when no argument is passed', () => {
expect(isMessageStoreMigrationWorkerData()).toBe(false);
});
});
describe('runMessageStoreMigrationWorker', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-ms-migration-worker-'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('rejects when the worker port is unavailable', async () => {
await expect(
runMessageStoreMigrationWorker(workerPayload(path.join(tmpDir, 'messages.db')), null),
).rejects.toThrow('database migration worker port is unavailable');
});
it('posts ready then a complete progress for an empty store', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
const control = createControlPort();
const close = vi.spyOn(MessageStoreMigrator.prototype, 'close');
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
expect(control.messages).toEqual([{ kind: 'ready' }]);
expect(control.listenerCount()).toBe(1);
control.send('start');
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{
kind: 'progress',
status: { phase: 'complete', processed: 0, total: 0 },
elapsedMs: expect.any(Number),
},
]);
expect(control.listenerCount()).toBe(0);
expect(close).toHaveBeenCalledOnce();
expect(await inspectMessageStoreMigration(dbPath)).toEqual({
phase: 'complete',
processed: 0,
total: 0,
});
});
it('does not start on an unrecognized control message', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
const control = createControlPort();
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('go');
control.send({ kind: 'start' });
await Promise.resolve();
expect(control.messages).toEqual([{ kind: 'ready' }]);
control.send('start');
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{
kind: 'progress',
status: { phase: 'complete', processed: 0, total: 0 },
elapsedMs: expect.any(Number),
},
]);
});
it('forwards preparation timings only after start without opening a worker log transport', async () => {
const { subscribeLogs } = await import('@snowluma/common/logger');
const { getFileTransport } = await import('@snowluma/common/log-file-transport');
const fileTransport = getFileTransport();
const write = vi.spyOn(fileTransport, 'write').mockImplementation(() => {});
const logs: string[] = [];
const unsubscribe = subscribeLogs((entry) => logs.push(entry.scope));
vi.stubEnv('SNOWLUMA_LOG_FILE', '1');
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 1);
const control = createControlPort();
try {
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await Promise.resolve();
expect(control.preparations).toEqual([]);
control.send('start');
await running;
expect(control.preparations).toEqual([
{ stage: 'started' },
{ stage: 'indexed', elapsedMs: expect.any(Number) },
{ stage: 'counted', total: 1, elapsedMs: expect.any(Number) },
]);
expect(
control.preparations
.filter((progress) => progress.stage !== 'started')
.every((progress) => progress.elapsedMs >= 0),
).toBe(true);
expect(logs).toEqual([]);
expect(write).not.toHaveBeenCalled();
} finally {
unsubscribe();
vi.unstubAllEnvs();
}
});
it('returns after cancel without posting progress', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 1);
const control = createControlPort();
const runBatch = vi.spyOn(MessageStoreMigrator.prototype, 'runBatch');
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('cancel');
await running;
expect(control.messages).toEqual([{ kind: 'ready' }]);
expect(runBatch).not.toHaveBeenCalled();
expect(control.listenerCount()).toBe(0);
expect(await inspectMessageStoreMigration(dbPath)).toEqual({
phase: 'migrating',
processed: 0,
total: 1,
});
});
it('treats start then cancel in the same turn as a cancel', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 1);
const control = createControlPort();
const runBatch = vi.spyOn(MessageStoreMigrator.prototype, 'runBatch');
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('start');
control.send('cancel');
await running;
expect(control.messages).toEqual([{ kind: 'ready' }]);
expect(runBatch).not.toHaveBeenCalled();
});
it('migrates pending rows in one batch and posts complete progress', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 1);
const control = createControlPort();
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('start');
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{
kind: 'progress',
status: { phase: 'complete', processed: 1, total: 1 },
elapsedMs: expect.any(Number),
},
]);
expect(await inspectMessageStoreMigration(dbPath)).toEqual({
phase: 'complete',
processed: 1,
total: 1,
});
});
it('posts a second progress after the batch pause when more than 200 rows remain', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 201);
const control = createControlPort();
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('start');
await running;
expect(control.messages).toHaveLength(3);
expect(control.messages[0]).toEqual({ kind: 'ready' });
expect(control.messages[1]).toEqual({
kind: 'progress',
status: { phase: 'migrating', processed: 200, total: 201 },
elapsedMs: expect.any(Number),
});
expect(control.messages[2]).toMatchObject({
kind: 'progress',
status: { phase: 'complete', processed: 201, total: 201 },
});
const last = control.messages[2];
expect(last?.kind).toBe('progress');
if (last?.kind === 'progress') {
expect(last.elapsedMs).toBeGreaterThanOrEqual(0);
}
expect(await inspectMessageStoreMigration(dbPath)).toEqual({
phase: 'complete',
processed: 201,
total: 201,
});
});
it('stops after cancel between batches', async () => {
vi.useFakeTimers();
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 201);
const control = createControlPort();
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('start');
await control.waitForMessages(2);
expect(control.messages).toEqual([
{ kind: 'ready' },
{
kind: 'progress',
status: { phase: 'migrating', processed: 200, total: 201 },
elapsedMs: expect.any(Number),
},
]);
control.send('cancel');
await vi.advanceTimersByTimeAsync(25);
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{
kind: 'progress',
status: { phase: 'migrating', processed: 200, total: 201 },
elapsedMs: expect.any(Number),
},
]);
expect(await inspectMessageStoreMigration(dbPath)).toEqual({
phase: 'migrating',
processed: 200,
total: 201,
});
});
it('posts failed when the database parent path is not a directory', async () => {
const parent = path.join(tmpDir, 'not-a-directory');
fs.writeFileSync(parent, 'blocked');
const dbPath = path.join(parent, 'messages.db');
let expected = '';
try {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
} catch (error) {
expected = error instanceof Error ? error.message : String(error);
}
const control = createControlPort();
await runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
expect(expected).not.toBe('');
expect(control.messages).toEqual([{ kind: 'failed', message: expected }]);
expect(control.listenerCount()).toBe(0);
});
it('posts failed when the messages table is missing after ready', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
const control = createControlPort();
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
expect(control.messages).toEqual([{ kind: 'ready' }]);
const db = new DatabaseSync(dbPath);
db.exec('DROP TABLE messages');
db.close();
control.send('start');
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{ kind: 'failed', message: expect.stringContaining('no such table: main.messages') },
]);
});
it('posts failed when a batch update is aborted', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
await seedUnclassified(dbPath, 1);
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TRIGGER fail_classify BEFORE UPDATE ON messages
BEGIN
SELECT RAISE(ABORT, 'classified row rejected');
END;
`);
db.close();
const control = createControlPort();
const close = vi.spyOn(MessageStoreMigrator.prototype, 'close');
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('start');
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{ kind: 'failed', message: expect.stringContaining('classified row rejected') },
]);
expect(close).toHaveBeenCalledOnce();
});
it('stringifies a non-Error thrown from a batch', async () => {
const dbPath = path.join(tmpDir, 'messages.db');
vi.spyOn(MessageStoreMigrator.prototype, 'runBatch').mockImplementation(() => {
throw 'raw-failure';
});
const control = createControlPort();
const running = runMessageStoreMigrationWorker(workerPayload(dbPath), control.port);
await control.waitForMessages(1);
control.send('start');
await running;
expect(control.messages).toEqual([
{ kind: 'ready' },
{ kind: 'failed', message: 'raw-failure' },
]);
});
});