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

@@ -115,19 +115,19 @@ export function registerSyncRoutes(app: import("express").Express): void {
formIds = Array.from(ids);
}
// Проверяем доступ пользователя к каждой форме
const accessibleFormIds: number[] = [];
for (const formId of formIds) {
const canAccess = await storage.canUserAccessForm(userId, formId, organizationId, 'participate');
if (canAccess) accessibleFormIds.push(formId);
}
// Проверяем доступ пользователя к каждой форме (параллельно, порядок сохраняется)
const accessFlags = await Promise.all(
formIds.map(formId => storage.canUserAccessForm(userId, formId, organizationId, 'participate'))
);
const accessibleFormIds = formIds.filter((_, i) => accessFlags[i]);
const forms: any[] = [];
const tasks: any[] = [];
const taskFieldValues: any[] = [];
const tableIds = new Set<number>();
for (const formId of accessibleFormIds) {
// Загружаем формы и их задачи параллельно; слияние результатов — в исходном порядке formIds
const perFormResults = await Promise.all(accessibleFormIds.map(async (formId) => {
const [form, formFields, formStatuses, formTabs, transitions] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
@@ -136,7 +136,24 @@ export function registerSyncRoutes(app: import("express").Express): void {
storage.getStatusTransitions(formId, organizationId),
]);
if (!form) continue;
if (!form) return null;
// Honor the form's offline-cache strategy: 'assigned' fetches only tasks
// assigned to the current user; 'recent' / 'all' fetch the latest/up to limit.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
const taskLimit = offlineCache?.maxTasks ?? OFFLINE_TASK_LIMIT;
const taskOptions: { limit: number; assignedTo?: number } = { limit: taskLimit };
if (offlineCache?.strategy === 'assigned') {
taskOptions.assignedTo = userId;
}
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
return { form, formFields, formStatuses, formTabs, transitions, formTasks };
}));
for (const result of perFormResults) {
if (!result) continue;
const { form, formFields, formStatuses, formTabs, transitions, formTasks } = result;
// Собираем ID справочников из полей типа table / select
for (const field of formFields) {
@@ -163,16 +180,6 @@ export function registerSyncRoutes(app: import("express").Express): void {
transitions,
});
// Honor the form's offline-cache strategy: 'assigned' fetches only tasks
// assigned to the current user; 'recent' / 'all' fetch the latest/up to limit.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
const taskLimit = offlineCache?.maxTasks ?? OFFLINE_TASK_LIMIT;
const taskOptions: { limit: number; assignedTo?: number } = { limit: taskLimit };
if (offlineCache?.strategy === 'assigned') {
taskOptions.assignedTo = userId;
}
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
for (const task of formTasks as any[]) {
const fieldValues = task.fieldValues || {};
delete task.fieldValues;
@@ -183,13 +190,12 @@ export function registerSyncRoutes(app: import("express").Express): void {
}
}
// Загружаем все доступные справочники организации
// Загружаем все доступные справочники организации (строки — параллельно)
const dataTables = await storage.getDataTablesByOrganization(organizationId);
const dataTableRows: any[] = [];
for (const table of dataTables) {
const rows = await storage.getDataTableRows(table.id, organizationId);
dataTableRows.push(...rows);
}
const rowsPerTable = await Promise.all(
dataTables.map(table => storage.getDataTableRows(table.id, organizationId))
);
const dataTableRows: any[] = rowsPerTable.flat();
const automations = await storage.getOfflineAutomations(organizationId);
const users = await storage.getUsersByOrganization(organizationId);
@@ -248,28 +254,30 @@ export function registerSyncRoutes(app: import("express").Express): void {
formIds = Array.from(ids);
}
const accessibleFormIds: number[] = [];
for (const formId of formIds) {
const canAccess = await storage.canUserAccessForm(userId, formId, organizationId, 'participate');
if (canAccess) accessibleFormIds.push(formId);
}
// Проверяем доступ пользователя к каждой форме (параллельно, порядок сохраняется)
const accessFlags = await Promise.all(
formIds.map(formId => storage.canUserAccessForm(userId, formId, organizationId, 'participate'))
);
const accessibleFormIds = formIds.filter((_, i) => accessFlags[i]);
const changedForms: any[] = [];
const changedTasks: any[] = [];
const changedFieldValues: any[] = [];
const deletedTaskIds: number[] = [];
for (const formId of accessibleFormIds) {
// Дельта по формам — параллельно; слияние результатов — в исходном порядке formIds
const perFormResults = await Promise.all(accessibleFormIds.map(async (formId) => {
const form = await storage.getForm(formId, organizationId);
if (form && (!form.updatedAt || new Date(form.updatedAt) >= since)) {
const [fields, statuses, tabs, transitions] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
]);
changedForms.push({ ...form, fields, statuses, tabs, transitions });
}
const formChanged = form && (!form.updatedAt || new Date(form.updatedAt) >= since);
const meta = formChanged
? await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
])
: null;
// Honor the form's offline-cache strategy for delta sync as well.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
@@ -279,6 +287,17 @@ export function registerSyncRoutes(app: import("express").Express): void {
}
// Фильтр по updated_at — на уровне SQL (раньше грузились ВСЕ задачи формы + JS-фильтр)
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
return { form, formChanged: !!formChanged, meta, formTasks };
}));
for (const result of perFormResults) {
const { form, formChanged, meta, formTasks } = result;
if (formChanged && meta) {
const [fields, statuses, tabs, transitions] = meta;
changedForms.push({ ...form, fields, statuses, tabs, transitions });
}
for (const task of formTasks as any[]) {
const fieldValues = task.fieldValues || {};
delete task.fieldValues;