perf(sse+tasks): индекс SSE-соединений, getTaskTree на recursive CTE, батчи N+1

Шаги 1.4 и 1.5 плана production-готовности:
- SseConnectionIndex (byOrg/byUser), один heartbeat-таймер, протокол SSE не тронут
- getTaskTree/getTaskParentChain: WITH RECURSIVE CTE (было 1+2N запросов)
- sync initial/delta: Promise.all по формам
- embedding queue/reindex: батч-предзагрузки вместо поштучных запросов
- 8 новых тестов (96/96)
This commit is contained in:
2026-09-07 23:29:21 +03:00
parent 9528a8123a
commit 667a321341
10 changed files with 518 additions and 121 deletions

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { SseConnectionIndex, type IndexedSSEConnection } from '../server/utils/sse-connection-index';
function conn(id: string, userId: number, organizationId: number): IndexedSSEConnection {
return { id, userId, organizationId };
}
describe('SseConnectionIndex', () => {
it('add/remove ведут учёт размера и очищают оба индекса', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
index.add(conn('b', 1, 10));
index.add(conn('c', 2, 20));
expect(index.size).toBe(3);
index.remove('a');
expect(index.size).toBe(2);
// Соединение удалено из всех индексов — не придёт ни по org, ни по user
expect(Array.from(index.getForEventTarget(10)).map(c => c.id)).toEqual(['b']);
expect(Array.from(index.getForEventTarget(10, 1)).map(c => c.id)).toEqual(['b']);
index.remove('b');
index.remove('c');
expect(index.size).toBe(0);
// Пустые Set'ы убраны из карт
expect(Array.from(index.getForEventTarget(10))).toEqual([]);
expect(Array.from(index.getForEventTarget(20, 2))).toEqual([]);
});
it('remove несуществующего id — no-op', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
expect(index.remove('missing')).toBeUndefined();
expect(index.size).toBe(1);
});
it('событие организации уходит только соединениям этой организации', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
index.add(conn('b', 2, 10));
index.add(conn('c', 3, 20));
const targets = Array.from(index.getForEventTarget(10)).map(c => c.id);
expect(targets).toEqual(['a', 'b']);
});
it('событие пользователя без organizationId уходит всем его соединениям (любая org)', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
index.add(conn('b', 1, 20));
index.add(conn('c', 2, 10));
const targets = Array.from(index.getForEventTarget(undefined, 1)).map(c => c.id);
expect(targets).toEqual(['a', 'b']);
});
it('событие пользователя + organizationId уходит только его соединениям в этой org', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
index.add(conn('b', 1, 20));
index.add(conn('c', 2, 10));
const targets = Array.from(index.getForEventTarget(10, 1)).map(c => c.id);
expect(targets).toEqual(['a']);
});
it('событие без userId и organizationId — broadcast на все соединения', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
index.add(conn('b', 2, 20));
const targets = Array.from(index.getForEventTarget(undefined, undefined)).map(c => c.id);
expect(targets).toEqual(['a', 'b']);
});
it('isUserConnected учитывает и пользователя, и организацию', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
expect(index.isUserConnected(1, 10)).toBe(true);
expect(index.isUserConnected(1, 20)).toBe(false);
expect(index.isUserConnected(2, 10)).toBe(false);
index.remove('a');
expect(index.isUserConnected(1, 10)).toBe(false);
});
it('forEach обходит все соединения (глобальный heartbeat)', () => {
const index = new SseConnectionIndex<IndexedSSEConnection>();
index.add(conn('a', 1, 10));
index.add(conn('b', 2, 20));
const seen: string[] = [];
index.forEach(c => seen.push(c.id));
expect(seen).toEqual(['a', 'b']);
});
});