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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user