SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,478 @@
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user