diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 0bdcbde..ed370c6 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -115,3 +115,28 @@ - Как проверялось: `docker run --entrypoint id` → uid=1001(app); `/app/data` и `/tmp` доступны на запись от app; document-worker на проде поднялся и healthy сразу. - Влияние на поиск/UX: нет. - Подводные камни: named volume `crm_crm_data` на проде был root-owned — на сервере заранее выполнен `chown -R 1001:1001` на `crm_crm_data` и `crm_crm_uploads` (иначе app не смог бы писать в /app/data). При развёртывании на новых серверах — учитывать. + +--- + +## [0.10] Лимиты и пагинация в /api/tasks + +- Статус: ✅ done +- Зачем: листинг не отдаёт до 10000 полных строк (аудит, Фаза 0). +- Что изменено: + - `server/utils/task-pagination.ts` — `clampTasksLimit` (дефолт 50, кап 200), курсор `_` (parse/build). + - `server/storage/tasks-core.storage.ts` — дефолт limit 50/кап 200 на уровне storage; опция `excludeDescription` (списковые ответы без тяжёлой text-колонки); курсорная пагинация `(updated_at, id)` с тай-брейкером; новый `getTasksCountByOrganization`. + - `server/routes/task-crud-list.routes.ts` — кап на роуте, параметр `cursor`, в ответе `nextCursor`; ключ minimal-кэша включает limit/курсор. + - Caller'ы: MCP search_tasks limit 500→200; embedding.service — pre-count через `getTasksCountByOrganization` вместо выгрузки миллиона строк; ShareTarget.tsx — явный `?limit=200`. + - `tests/task-pagination.test.ts` — 11 юнит-тестов (кламп, курсор, round-trip). +- Как проверялось: `.toSQL()` — курсор и тай-брейкер корректны, списковый SELECT без description; `npm run check` чисто; `npx vitest run` 56/56. +- Влияние на поиск/UX: search-ветка не изменена (SQL, лимиты, формат) — инвариант сохранён. Дельта: список без параметров — 50 строк вместо 10000, без description; кастомные JS-виджеты (Bots.tsx, CustomPageView) получают задачи без description (при необходимости дотягивают деталь). +- Подводные камни: `/api/forms/:id/tasks` и `/with-fields` НЕ тронуты (там description остаётся — реестр/канбан/Гантт используют их). + +## [0.9] Дельта-синхронизация: фильтр на уровне SQL + +- Статус: ✅ done +- Зачем: sync не выгружает все задачи всех форм (аудит, Фаза 0). +- Что изменено: `getTasksWithFieldsByFormOptimized` и `getUsersByOrganization` принимают `since?: Date` → `WHERE updated_at >= since OR updated_at IS NULL` (паритет со старым JS-фильтром); JS-фильтры в `sync.routes.ts` удалены; initial sync не тронут. +- Как проверялось: `.toSQL()` — условие в SQL; индекс `tasks_form_updated_idx` (0079) покрывает; vitest 56/56. +- Влияние на поиск/UX: нет. +- Подводные камни: `OR IS NULL` обязателен — старые строки с NULL updated_at должны попадать в дельту (паритет с `!t.updatedAt ||` в JS). diff --git a/client/src/pages/ShareTarget.tsx b/client/src/pages/ShareTarget.tsx index a6939e5..17e87d4 100644 --- a/client/src/pages/ShareTarget.tsx +++ b/client/src/pages/ShareTarget.tsx @@ -60,7 +60,7 @@ export default function ShareTarget() { const conversations = convsData?.conversations ?? []; const { data: tasksData } = useQuery<{ success: boolean; tasks: Task[] }>({ - queryKey: ['/api/tasks'], + queryKey: ['/api/tasks?limit=200'], enabled: destType === 'task', }); const tasks = tasksData?.tasks ?? []; diff --git a/server/mcp.ts b/server/mcp.ts index 83461fd..a1fa2a9 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -1164,7 +1164,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco if (form_id) { allTasks = await storage.getTasksByForm(form_id, organizationId); } else { - allTasks = (await storage.getTasksByOrganization(organizationId, { limit: 500, minimal: false })) as Task[]; + allTasks = (await storage.getTasksByOrganization(organizationId, { limit: 200, minimal: false })) as Task[]; // Фильтруем выдачу по разрешённым формам allTasks = allTasks.filter((t) => isFormAllowed(t.formId)); } diff --git a/server/routes/sync.routes.ts b/server/routes/sync.routes.ts index 8dd370e..0eec872 100644 --- a/server/routes/sync.routes.ts +++ b/server/routes/sync.routes.ts @@ -258,7 +258,6 @@ export function registerSyncRoutes(app: import("express").Express): void { const changedTasks: any[] = []; const changedFieldValues: any[] = []; const deletedTaskIds: number[] = []; - const changedUsers: any[] = []; for (const formId of accessibleFormIds) { const form = await storage.getForm(formId, organizationId); @@ -274,13 +273,13 @@ export function registerSyncRoutes(app: import("express").Express): void { // 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; - const taskOptions: { assignedTo?: number } = {}; + const taskOptions: { assignedTo?: number; since?: Date } = { since }; if (offlineCache?.strategy === 'assigned') { taskOptions.assignedTo = userId; } + // Фильтр по updated_at — на уровне SQL (раньше грузились ВСЕ задачи формы + JS-фильтр) const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions); - const recentTasks = (formTasks as any[]).filter(t => !t.updatedAt || new Date(t.updatedAt) >= since); - for (const task of recentTasks) { + for (const task of formTasks as any[]) { const fieldValues = task.fieldValues || {}; delete task.fieldValues; changedTasks.push(task); @@ -292,12 +291,8 @@ export function registerSyncRoutes(app: import("express").Express): void { // TODO: deleted tasks tracking через task_audit_log } - const allUsers = await storage.getUsersByOrganization(organizationId); - for (const user of allUsers) { - if (!user.updatedAt || new Date(user.updatedAt) >= since) { - changedUsers.push(user); - } - } + // Фильтр по updated_at — на уровне SQL (раньше: полная выгрузка + JS-фильтр) + const changedUsers = await storage.getUsersByOrganization(organizationId, { since }); res.json({ success: true, diff --git a/server/routes/task-crud-list.routes.ts b/server/routes/task-crud-list.routes.ts index c2523c1..1a1e6d8 100644 --- a/server/routes/task-crud-list.routes.ts +++ b/server/routes/task-crud-list.routes.ts @@ -6,6 +6,7 @@ import { type Task, type FormStatus, type SafeUser, roles, tasks } from "@shared import { db } from "../db"; import { eq, inArray } from "drizzle-orm"; import { tasksMinimalCache } from "../utils/cache"; +import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination"; function safeParseOptions(opts: unknown): unknown { if (Array.isArray(opts)) return opts; @@ -72,19 +73,23 @@ async function buildRelatedTaskTitles( export function registerTaskCrudListRoutes(router: ReturnType): void { router.get('/api/tasks', async (req: AuthenticatedRequest, res) => { try { - const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; + const requestedLimit = req.query.limit ? parseInt(req.query.limit as string) : undefined; + // Дефолт 50, жёсткий кап 200 — без limit больше не уходит до 10000 полных строк + const effectiveLimit = clampTasksLimit(requestedLimit); const sortBy = (req.query.sortBy as 'updatedAt' | 'createdAt') || 'updatedAt'; const order = (req.query.order as 'asc' | 'desc') || 'desc'; const statusId = req.query.statusId ? parseInt(req.query.statusId as string) : undefined; const assignedTo = req.query.assignedTo ? parseInt(req.query.assignedTo as string) : undefined; const minimal = req.query.minimal === 'true'; const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + // Курсорная пагинация: cursor = "_" (невалидный — игнорируется) + const cursor = parseTasksCursor(typeof req.query.cursor === 'string' ? req.query.cursor : null); const appRole = req.user!.appRole; const accessibleIds = await storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole); if (search) { - const searchLimit = limit && limit > 0 ? Math.min(limit, 100) : 50; + const searchLimit = requestedLimit && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 50; let tasks = await storage.searchTasksByKeyword(req.organizationId!, search, searchLimit); if (accessibleIds !== null) { tasks = tasks.filter((t: any) => accessibleIds.has(t.id)); @@ -92,24 +97,35 @@ export function registerTaskCrudListRoutes(router: ReturnType): string | null => { + if (sortBy !== 'updatedAt' || order !== 'desc' || list.length < effectiveLimit) return null; + const last = list[list.length - 1]; + return last && last.updatedAt ? buildTasksNextCursor({ id: last.id, updatedAt: last.updatedAt }) : null; + }; + const cacheKey = (minimal && accessibleIds === null) - ? `tasks:${req.organizationId}:minimal:${limit ?? ''}:${sortBy}:${order}:${statusId ?? ''}:${assignedTo ?? ''}` + ? `tasks:${req.organizationId}:minimal:${effectiveLimit}:${sortBy}:${order}:${statusId ?? ''}:${assignedTo ?? ''}:${cursor ? buildTasksNextCursor(cursor) ?? '' : ''}` : null; if (cacheKey) { const cached = tasksMinimalCache.get(cacheKey); if (cached) { - return res.json({ success: true, tasks: cached }); + return res.json({ success: true, tasks: cached, nextCursor: computeNextCursor(cached) }); } } let tasks = await storage.getTasksByOrganization(req.organizationId!, { - limit: limit && limit > 0 ? Math.min(limit, 100) : undefined, + limit: effectiveLimit, sortBy, order, statusId: statusId && !isNaN(statusId) ? statusId : undefined, assignedTo: assignedTo && !isNaN(assignedTo) ? assignedTo : undefined, - minimal + minimal, + // Списковый ответ без description: тяжёлая text-колонка в реестрах не нужна. + // Поиск (выше) и деталь задачи (/api/tasks/:id) description сохраняют. + excludeDescription: true, + cursor, }); if (accessibleIds !== null) { @@ -120,7 +136,7 @@ export function registerTaskCrudListRoutes(router: ReturnType) }); } catch (error) { console.error('Get all tasks error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении задач' }); diff --git a/server/services/embedding.service.ts b/server/services/embedding.service.ts index 76496e2..a3702eb 100644 --- a/server/services/embedding.service.ts +++ b/server/services/embedding.service.ts @@ -652,16 +652,10 @@ export async function reindexOrganization( let totalAttempted = 0; let totalFailed = 0; - // ── 1. Pre-count: fetch minimal task list to know total for progress ───────── + // ── 1. Pre-count: точный COUNT(*) без выгрузки строк (список теперь капнут на 200) ── let taskCount = 0; if (entityTypes.includes("task") || entityTypes.includes("task_message")) { - const minimalTasks = await stor.getTasksByOrganization(organizationId, { - sortBy: "createdAt", - order: "asc", - minimal: true, - limit: 1_000_000, - }); - taskCount = minimalTasks.length; + taskCount = await stor.getTasksCountByOrganization(organizationId); } const formCount = entityTypes.includes("form") diff --git a/server/storage.ts b/server/storage.ts index a90fc6a..050edb0 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -42,7 +42,7 @@ export interface IStorage { getUserWithOrganization(id: number): Promise; getUserByEmail(email: string, organizationId?: number): Promise; getUserByEmailAndSlug(email: string, slug: string): Promise; - getUsersByOrganization(organizationId: number): Promise; + getUsersByOrganization(organizationId: number, options?: { since?: Date }): Promise; listUsers(organizationId: number, options?: import('./storage/users.storage').ListUsersOptions): Promise; getLinkedTasksForUser(userId: number, organizationId: number): Promise< Array<{ @@ -182,7 +182,7 @@ export interface IStorage { } | null>; getTasksByForm(formId: number, organizationId: number): Promise; getTasksWithFieldsByForm(formId: number, organizationId: number): Promise; - getTasksWithFieldsByFormOptimized(formId: number, organizationId: number, options?: { limit?: number; offset?: number; assignedTo?: number }): Promise<{ tasks: Task[]; total: number }>; + getTasksWithFieldsByFormOptimized(formId: number, organizationId: number, options?: { limit?: number; offset?: number; assignedTo?: number; since?: Date }): Promise<{ tasks: Task[]; total: number }>; getTasksByOrganization(organizationId: number, options?: { limit?: number; offset?: number; @@ -191,7 +191,10 @@ export interface IStorage { statusId?: number; assignedTo?: number; minimal?: boolean; + excludeDescription?: boolean; + cursor?: import('./utils/task-pagination').TasksCursor | null; }): Promise[]>; + getTasksCountByOrganization(organizationId: number): Promise; createTask(insertTask: InsertTask): Promise; updateTask(id: number, organizationId: number, updates: Partial): Promise; updateTaskIfNotModified(id: number, organizationId: number, updates: Partial, requiredUpdatedAt: Date): Promise; diff --git a/server/storage/tasks-core.storage.ts b/server/storage/tasks-core.storage.ts index 2830527..fa1fb87 100644 --- a/server/storage/tasks-core.storage.ts +++ b/server/storage/tasks-core.storage.ts @@ -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 { @@ -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[]> { 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 { + const result = await db + .select({ count: sql`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 { let depth = 0; let position = 0; diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index 721915a..42cdb98 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -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 { + // Массовая выдача пользователей клиенту — только безопасные колонки (без хэша пароля и токенов). + // options.since — дельта-синк: фильтр по updated_at на уровне SQL + // (NULL updated_at включаем для паритета со старым JS-фильтром). + async getUsersByOrganization(organizationId: number, options?: { since?: Date }): Promise { + 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)); } diff --git a/server/utils/task-pagination.ts b/server/utils/task-pagination.ts new file mode 100644 index 0000000..22c4411 --- /dev/null +++ b/server/utils/task-pagination.ts @@ -0,0 +1,44 @@ +/** + * Хелперы пагинации спискового endpoint'а GET /api/tasks. + * Дефолтный лимит 50, жёсткий максимум 200 — защита от выгрузки + * всей таблицы задач одним запросом без параметров. + */ + +export const TASKS_LIST_DEFAULT_LIMIT = 50; +export const TASKS_LIST_MAX_LIMIT = 200; + +/** Курсорная пагинация по (updatedAt, id): ключевая пара последней строки выдачи */ +export interface TasksCursor { + updatedAt: Date; + id: number; +} + +/** Приводит запрошенный лимит к диапазону 1..200; без значения — дефолт 50 */ +export function clampTasksLimit(requested?: number | null): number { + if (requested == null || Number.isNaN(requested) || requested <= 0) { + return TASKS_LIST_DEFAULT_LIMIT; + } + return Math.min(Math.floor(requested), TASKS_LIST_MAX_LIMIT); +} + +/** + * Парсит курсор формата `_`. + * Невалидный курсор возвращает null — запрос обрабатывается как первая страница. + */ +export function parseTasksCursor(raw?: string | null): TasksCursor | null { + if (!raw) return null; + const idx = raw.lastIndexOf('_'); + if (idx <= 0 || idx === raw.length - 1) return null; + const ts = new Date(raw.slice(0, idx)).getTime(); + const id = Number(raw.slice(idx + 1)); + if (Number.isNaN(ts) || !Number.isInteger(id) || id <= 0) return null; + return { updatedAt: new Date(ts), id }; +} + +/** Собирает nextCursor из последней строки выдачи; null, если у строки нет updatedAt */ +export function buildTasksNextCursor(task: { id: number; updatedAt: Date | string | null }): string | null { + if (!task.updatedAt) return null; + const ts = new Date(task.updatedAt).getTime(); + if (Number.isNaN(ts)) return null; + return `${new Date(ts).toISOString()}_${task.id}`; +} diff --git a/tests/task-pagination.test.ts b/tests/task-pagination.test.ts new file mode 100644 index 0000000..40d96a5 --- /dev/null +++ b/tests/task-pagination.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { + clampTasksLimit, + parseTasksCursor, + buildTasksNextCursor, + TASKS_LIST_DEFAULT_LIMIT, + TASKS_LIST_MAX_LIMIT, +} from '../server/utils/task-pagination'; + +describe('clampTasksLimit', () => { + it('без значения возвращает дефолт 50', () => { + expect(clampTasksLimit(undefined)).toBe(TASKS_LIST_DEFAULT_LIMIT); + expect(clampTasksLimit(null)).toBe(TASKS_LIST_DEFAULT_LIMIT); + expect(TASKS_LIST_DEFAULT_LIMIT).toBe(50); + }); + + it('неположительные и невалидные значения — дефолт 50', () => { + expect(clampTasksLimit(0)).toBe(50); + expect(clampTasksLimit(-5)).toBe(50); + expect(clampTasksLimit(NaN)).toBe(50); + }); + + it('явный лимит в пределах максимума проходит как есть', () => { + expect(clampTasksLimit(1)).toBe(1); + expect(clampTasksLimit(100)).toBe(100); + expect(clampTasksLimit(200)).toBe(200); + }); + + it('капает сверху на 200', () => { + expect(clampTasksLimit(201)).toBe(TASKS_LIST_MAX_LIMIT); + expect(clampTasksLimit(500)).toBe(200); + expect(clampTasksLimit(10000)).toBe(200); + expect(TASKS_LIST_MAX_LIMIT).toBe(200); + }); +}); + +describe('parseTasksCursor', () => { + it('парсит валидный курсор _', () => { + const cursor = parseTasksCursor('2026-09-08T12:34:56.789Z_123'); + expect(cursor).not.toBeNull(); + expect(cursor!.id).toBe(123); + expect(cursor!.updatedAt.toISOString()).toBe('2026-09-08T12:34:56.789Z'); + }); + + it('null/пустая строка — null (первая страница)', () => { + expect(parseTasksCursor(null)).toBeNull(); + expect(parseTasksCursor(undefined)).toBeNull(); + expect(parseTasksCursor('')).toBeNull(); + }); + + it('невалидные форматы — null', () => { + expect(parseTasksCursor('abc')).toBeNull(); + expect(parseTasksCursor('_123')).toBeNull(); + expect(parseTasksCursor('2026-09-08T12:34:56.789Z_')).toBeNull(); + expect(parseTasksCursor('2026-09-08T12:34:56.789Z_abc')).toBeNull(); + expect(parseTasksCursor('not-a-date_123')).toBeNull(); + expect(parseTasksCursor('2026-09-08T12:34:56.789Z_0')).toBeNull(); + expect(parseTasksCursor('2026-09-08T12:34:56.789Z_-1')).toBeNull(); + expect(parseTasksCursor('2026-09-08T12:34:56.789Z_1.5')).toBeNull(); + }); +}); + +describe('buildTasksNextCursor', () => { + it('собирает курсор из последней строки выдачи', () => { + const cursor = buildTasksNextCursor({ id: 42, updatedAt: new Date('2026-09-08T10:00:00.000Z') }); + expect(cursor).toBe('2026-09-08T10:00:00.000Z_42'); + }); + + it('принимает updatedAt строкой', () => { + const cursor = buildTasksNextCursor({ id: 7, updatedAt: '2026-09-08T10:00:00.000Z' }); + expect(cursor).toBe('2026-09-08T10:00:00.000Z_7'); + }); + + it('без updatedAt — null', () => { + expect(buildTasksNextCursor({ id: 1, updatedAt: null })).toBeNull(); + }); + + it('round-trip: build → parse возвращает ту же пару', () => { + const built = buildTasksNextCursor({ id: 555, updatedAt: new Date('2026-01-02T03:04:05.006Z') }); + const parsed = parseTasksCursor(built); + expect(parsed).not.toBeNull(); + expect(parsed!.id).toBe(555); + expect(parsed!.updatedAt.toISOString()).toBe('2026-01-02T03:04:05.006Z'); + }); +});