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; } { 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((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 { 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' }, ]); }); });