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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { users, organizations, userSessions, invitations, userCustomFields, userCustomValues, roleMembers, forms, formFields, formStatuses, tasks, taskFieldValues, userOfflineSubscriptions, safeUserColumns, type User, type SafeUser, type Organization, type UserSession, type Invitation, type InsertUser, type InsertOrganization, type InsertUserSession, type InsertInvitation, type UserWithOrganization, type UserCustomField, type UserCustomValue, type CreateUserCustomField, type UpdateUserCustomField, type Task, type FormField, type FormStatus, type UserOfflineSubscription, type InsertUserOfflineSubscription } from "@shared/schema";
|
||||
import { db } from "../db";
|
||||
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray } from "drizzle-orm";
|
||||
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
|
||||
import crypto from "crypto";
|
||||
import { userStatusesStorage } from "./user-statuses.storage";
|
||||
|
||||
@@ -90,12 +90,21 @@ export class UsersStorage {
|
||||
};
|
||||
}
|
||||
|
||||
// Массовая выдача пользователей клиенту — только безопасные колонки (без хэша пароля и токенов)
|
||||
async getUsersByOrganization(organizationId: number): Promise<SafeUser[]> {
|
||||
// Массовая выдача пользователей клиенту — только безопасные колонки (без хэша пароля и токенов).
|
||||
// options.since — дельта-синк: фильтр по updated_at на уровне SQL
|
||||
// (NULL updated_at включаем для паритета со старым JS-фильтром).
|
||||
async getUsersByOrganization(organizationId: number, options?: { since?: Date }): Promise<SafeUser[]> {
|
||||
const conditions = [eq(users.organizationId, organizationId)];
|
||||
if (options?.since) {
|
||||
conditions.push(or(
|
||||
gte(users.updatedAt, options.since),
|
||||
isNull(users.updatedAt)
|
||||
)!);
|
||||
}
|
||||
return await db
|
||||
.select(safeUserColumns)
|
||||
.from(users)
|
||||
.where(eq(users.organizationId, organizationId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(users.createdAt));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user