perf(tasks): лимиты/пагинация /api/tasks и дельта-синк в SQL

Шаги 0.10 и 0.9 плана production-готовности:
- дефолт limit 50, кап 200; списковые ответы без description
- курсорная пагинация (updated_at, id) + nextCursor
- since-фильтр дельта-синка перенесён в SQL (updated_at >= since)
- embedding pre-count через COUNT(*) вместо выгрузки миллиона строк
- 11 новых юнит-тестов (56/56 зелёные)

Инвариант поиска сохранён: search-ветка /api/tasks?search= не изменена
This commit is contained in:
2026-09-07 22:01:25 +03:00
parent 0a60c6abf1
commit a27766a38b
11 changed files with 275 additions and 40 deletions

View File

@@ -2,9 +2,10 @@ import { users, forms, formStatuses, tasks, taskFieldValues, fieldHistory, type
import { taskRelations } from "@shared/schema";
import { taskAssignees } from "@shared/schema";
import { db } from "../db";
import { eq, and, or, desc, asc, exists, sql, ilike, inArray } from "drizzle-orm";
import { eq, and, or, desc, asc, exists, sql, ilike, inArray, lt, gte, isNull } from "drizzle-orm";
import { formatUserName } from "../utils/formatUserName";
import { FormsStorage } from "./forms.storage";
import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination";
export class TasksCoreStorage extends FormsStorage {
// Tasks
async getTask(id: number, organizationId: number): Promise<Task | undefined> {
@@ -61,7 +62,7 @@ export class TasksCoreStorage extends FormsStorage {
async getTasksWithFieldsByFormOptimized(
formId: number,
organizationId: number,
options?: { limit?: number; offset?: number; assignedTo?: number }
options?: { limit?: number; offset?: number; assignedTo?: number; since?: Date }
): Promise<{ tasks: Task[]; total: number }> {
const limit = options?.limit;
const offset = options?.offset || 0;
@@ -73,6 +74,14 @@ export class TasksCoreStorage extends FormsStorage {
if (options?.assignedTo != null) {
whereConditions.push(eq(tasks.assignedTo, options.assignedTo));
}
// Дельта-синк: фильтр по updated_at на уровне SQL (индекс tasks_form_updated_idx).
// NULL updated_at включаем для паритета со старым JS-фильтром (!t.updatedAt || >= since).
if (options?.since) {
whereConditions.push(or(
gte(tasks.updatedAt, options.since),
isNull(tasks.updatedAt)
)!);
}
let query = db
.select()
@@ -178,11 +187,14 @@ export class TasksCoreStorage extends FormsStorage {
statusId?: number;
assignedTo?: number;
minimal?: boolean;
excludeDescription?: boolean;
cursor?: TasksCursor | null;
}
): Promise<Task[] | Partial<Task>[]> {
const sortBy = options?.sortBy || 'updatedAt';
const order = options?.order || 'desc';
const limit = options?.limit;
// Дефолт 50, жёсткий кап 200 — список никогда не выгружает таблицу целиком
const limit = clampTasksLimit(options?.limit);
const offset = options?.offset;
const statusId = options?.statusId;
const assignedTo = options?.assignedTo;
@@ -199,6 +211,18 @@ export class TasksCoreStorage extends FormsStorage {
if (assignedTo) {
conditions.push(eq(tasks.assignedTo, assignedTo));
}
// Курсорная пагинация работает только для дефолтной сортировки (updatedAt DESC, id DESC)
const cursor = options?.cursor ?? null;
const useCursor = cursor !== null && sortBy === 'updatedAt' && order === 'desc';
if (useCursor) {
conditions.push(or(
lt(tasks.updatedAt, cursor.updatedAt),
and(eq(tasks.updatedAt, cursor.updatedAt), lt(tasks.id, cursor.id))
)!);
}
// В курсорном режиме добавляем id как детерминированный тай-брейкер
const orderByClause = useCursor ? [desc(tasks.updatedAt), desc(tasks.id)] : [orderFn(sortColumn)];
if (minimal) {
return await db
@@ -216,23 +240,63 @@ export class TasksCoreStorage extends FormsStorage {
.innerJoin(forms, eq(tasks.formId, forms.id))
.leftJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
.where(and(...conditions))
.orderBy(orderFn(sortColumn))
.limit(limit || 1000)
.orderBy(...orderByClause)
.limit(limit)
.offset(offset || 0);
}
// Списковый ответ без description (тяжёлая text-колонка) — только по явному флагу;
// остальные caller'ы (embedding, MCP) получают полную строку, как раньше
if (options?.excludeDescription) {
const rows = await db
.select({
id: tasks.id,
organizationId: tasks.organizationId,
formId: tasks.formId,
title: tasks.title,
currentStatusId: tasks.currentStatusId,
assignedTo: tasks.assignedTo,
createdBy: tasks.createdBy,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
completedAt: tasks.completedAt,
isCompleted: tasks.isCompleted,
dueDate: tasks.dueDate,
parentTaskId: tasks.parentTaskId,
depth: tasks.depth,
position: tasks.position,
})
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(...conditions))
.orderBy(...orderByClause)
.limit(limit)
.offset(offset || 0);
return rows as unknown as Task[];
}
const results = await db
.select()
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(...conditions))
.orderBy(orderFn(sortColumn))
.limit(limit || 10000)
.orderBy(...orderByClause)
.limit(limit)
.offset(offset || 0);
return results.map((r) => r.tasks);
}
/** Точный COUNT(*) задач организации — для оценки прогресса без выгрузки строк */
async getTasksCountByOrganization(organizationId: number): Promise<number> {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(eq(forms.organizationId, organizationId));
return Number(result[0]?.count || 0);
}
async createTask(insertTask: InsertTask): Promise<Task> {
let depth = 0;
let position = 0;