Files
iistwin/IMPLEMENTATION_LOG.md
Ильяс Султанов 23c49adcd1 perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией
Шаг 0.12 плана production-готовности:
- accessibleTasksCache (45с) per userId:orgId внутри storage-метода
- инвалидация в storage-слое: роли, делегирования, form access,
  assignees, createTask/updateTask, appRole пользователя
- 9 новых юнит-тестов (65/65 зелёные)
2026-09-07 22:27:33 +03:00

20 KiB
Raw Blame History

IMPLEMENTATION_LOG — доведение iistwin до коммерческой готовности

Контекст для любого ИИ-агента / разработчика

Проект: iistwin (TaskTitan) — CRM/таск-трекер. Стек: React 18 + TypeScript + Vite + Wouter + TanStack Query + shadcn/ui (клиент client/src), Express + Drizzle ORM + PostgreSQL с мультитенантностью через organizationId (сервер server/), JWT (access + refresh), SSE real-time. Миграции — детерминированный SQL-раннер в migrations/ (scripts/migrate-docker.js, НЕ drizzle-kit). Деплой: push в main → GitHub Actions deploy.yml → Docker на VPS 82.202.130.22 (прод: https://iistwin.ru). Финансы — субапп /di2 (client-di2/ + server/finance-di2/).

Инвариант (жёсткое требование владельца): механика мгновенного глобального поиска — на главной (client/src/components/HomeGlobalSearch.tsx), в реестре задач (TaskRegistry.tsx) и в канбане (TaskKanban.tsx) — НЕ должна измениться ни в поведении, ни в скорости ощущений. API GET /api/tasks?search=&limit= и подстроковая семантика поиска (ilike '%…%' находит совпадения внутри слов, «омент» → «моментально») сохраняются. Ускорение поиска — только через pg_trgm GIN-индексы, НЕ через tsvector (он меняет семантику).

Источники: этот лог ведётся по плану «план улучшения до продакт.md» (аудит production-готовности) и исполнительному плану Kimi Code. Нумерация шагов ниже соответствует исходному плану (Фаза 0: 0.0–0.15, Фаза 1: 1.1–1.10, Фаза 2: 2.1–2.5). Дополнительный шаг A3 (подключение тестов) вынесен вперёд из 1.6.

Принятые решения:

  • Биллинг (0.13): только абстракция PaymentProvider + MockPaymentProvider; реальный эквайринг — позже, отдельным этапом.
  • Шаг 0.14: серверные кастомизации — в отдельный приватный инфра-репозиторий (основной репозиторий остаётся как есть).

Как читать файл: записи идут в порядке выполнения (не обязательно совпадает с нумерацией). Каждая запись — по шаблону: статус, зачем, что изменено, ключевые файлы, коммит, как проверялось, влияние на поиск/UX, подводные камни. Ничего не менять без записи в этот лог.


[0.0] Обновить локальный репозиторий

  • Статус: ✅ done
  • Зачем: рабочая копия должна соответствовать актуальному main на GitHub.
  • Что изменено: — (только проверка)
  • Как проверялось: git fetch origin + git status (чисто, синхронизировано с origin/main, HEAD a2b231d), npm run check — зелёный (оба tsc: основной и client-di2).
  • Влияние на поиск/UX: нет.

[0.15] Сквозная документация изменений (этот файл)

  • Статус: ✅ done (коммит 76a0d85 «chore(docs): add implementation log»)
  • Зачем: по одному файлу любой ИИ-агент понимает, что сделано, зачем, какие файлы тронуты и как проверялось.
  • Что изменено: создан IMPLEMENTATION_LOG.md в корне репозитория (шапка-контекст + шаблон записей).
  • Влияние на поиск/UX: нет.

[A3 / 1.6 частично] Подключение тестов + починка 5 падающих

  • Статус: ✅ done
  • Зачем: тесты существовали (3 файла, vitest), но не запускались ни локально (нет скрипта), ни в CI; 5 из 45 падали. Без зелёных тестов дальнейшие изменения безопасности/производительности — вслепую.
  • Что изменено:
    • package.json — скрипт "test": "vitest run".
    • .github/workflows/pr-check.yml — шаг «Tests» (npm test) после type check.
    • server/utils/userActivity.ts — фикс реального бага: trackUserActivity мог кинуть синхронно внутри authenticateToken, исключение попадало в catch аутентификации → ложный 403 Недействительный токен на первом запросе пользователя за минуту. Тело обёрнуто в try/catch, throttle сбрасывается для повторной попытки (соответствие контракту fire-and-forget).
    • tests/auto-transitions.test.ts — 3 устаревших теста обновлены под намеренное текущее поведение (forward-only по position; синк assignee в task_assignees; системное сообщение заменено аудит-записью «Автопереход»), добавлен мок delegation.service.
  • Как проверялось: npx vitest run — 45/45 зелёные; npm run check — чисто.
  • Влияние на поиск/UX: нет.
  • Подводные камни: баг в userActivity.ts проявлялся только на первом аутентифицированном запросе в минуту (throttle) — в проде мог давать sporadic 403.

[0.7] Обработчик ошибок без утечки деталей

  • Статус: ✅ done
  • Зачем: клиент не должен видеть SQL и внутренности БД в 5xx-ответах (аудит, Фаза 0).
  • Что изменено:
    • server/index.ts — глобальный error handler: при status ≥ 500 отдаёт «Внутренняя ошибка сервера (код XXXXXXXX)», оригинальная ошибка — в серверный лог с тем же кодом инцидента. 4xx не тронуты.
    • Route-уровень (12 файлов): зачищены все 5xx-ответы с err.message/String(err)/телом апстрима — auth.users.routes.ts, form-offline.routes.ts, sync.routes.ts, polls.routes.ts, reactions.routes.ts, mcp-rag.routes.ts, llm-providers.routes.ts, external.routes.ts, task-crud-write.routes.ts, task-fields.routes.ts, finance-di2/routes.ts, documents/worker/server.ts. Где не было — добавлен console.error с контекстом.
  • Как проверялось: повторный grep status(5xx) + err.message по server/ — 0 совпадений; npm run check чисто; npx vitest run 45/45.
  • Влияние на поиск/UX: нет (только тексты 5xx-ответов).
  • Подводные камни: остались ответы 200 + success:false с err?.message (llm-providers, rag, finance-di2) и 4xx с err.message (documents, data-tables) — сознательно вне скоупа шага, кандидаты для фазы 2.

[0.8] Убрать passwordHash из выдачи пользователей

  • Статус: ✅ done
  • Зачем: хэши паролей и токены не должны уходить клиенту (в т.ч. через offline-sync).
  • Что изменено:
    • shared/schema.ts — тип SafeUser (User без passwordHash/verificationToken/resetPasswordToken/resetPasswordExpires) + набор колонок safeUserColumns (единая точка правды).
    • server/storage/users.storage.ts — getUsersByOrganization и listUsers → SafeUser[].
    • server/storage/social.storage.ts (searchUsers), server/storage/task-meta.storage.ts (getUsersByRole, getUsersByOrgRoleId), server/documents/data-resolution.service.ts (getUser для шаблонов) — тоже на safeUserColumns.
    • Сигнатуры в server/storage.ts; типовые правки у caller'ов (логика не менялась). Методы аутентификации (где passwordHash нужен) не тронуты.
    • Побочный эффект: ctx.users.list() в автоматизациях и MCP list_users тоже sanitized.
  • Как проверялось: ни один из 43 caller'ов не использовал исключённые поля (tsc); sync (initial + delta) теперь отдаёт пользователей без хэшей; npm run check чисто; npx vitest run 45/45; grep passwordHash по server/ — только auth-флоу.
  • Влияние на поиск/UX: нет.
  • Подводные камни: при явном списке колонок drizzle возвращает плоские строки даже с join — мёртвый маппинг в listUsers убран, на это опираться нельзя в будущих правках.

[0.11] Миграция индексов (0079_performance_indexes.sql)

  • Статус: ✅ done
  • Зачем: убрать Seq Scan на горячих путях — поиск задач, дельта-синк, история полей, логин по email.
  • Что изменено: migrations/0079_performance_indexes.sql — CREATE EXTENSION IF NOT EXISTS pg_trgm; GIN (gin_trgm_ops) на tasks.title и tasks.description; (organization_id, updated_at DESC) и (form_id, updated_at) на tasks; field_history(task_id); users(email). Все с IF NOT EXISTS, без CONCURRENTLY (раннер в транзакции).
  • Как проверялось: миграция применена на scratch-контейнере postgres:16-alpine с 100k синтетических задач — все 7 операторов чисто. EXPLAIN ANALYZE поиска ilike '%омент%' (совпадение внутри слова — инвариант): полный проход 77.5 мс (Seq Scan) → 4.8 мс (Bitmap Index Scan по trgm + BitmapAnd с org-индексом), ускорение ~16×; с LIMIT 50 — 10.8 мс → 9.7 мс. Отдельно проверено: CREATE EXTENSION pg_trgm выполняется не-суперпользователем-владельцем БД (trusted extension, PG 13+) — на проде миграция пройдёт под appuser.
  • Влияние на поиск/UX: семантика НЕ меняется (тот же ilike, тот же API) — только скорость.
  • Подводные камни: на проде построение GIN-индексов без CONCURRENTLY кратковременно блокирует запись в tasks — при текущих объёмах (десятки тысяч задач) это секунды, приемлемо.

[0.5] Чистый production-образ (без devDependencies)

  • Статус: ✅ done (коммиты a54ec77 (Dockerfile), 9fc6ad1, 5426c70 (фиксы запуска))
  • Зачем: в прод-образ не должны попадать typescript/vite/vitest — поверхность атаки и размер.
  • Что изменено:
    • Dockerfile — production-стадия: npm ci --omit=dev вместо копирования node_modules из builder.
    • package.json — vitest/supertest/@types/* (16 пакетов) перенесены в devDependencies (именно vitest в dependencies тянул vite/esbuild/tsx в прод-образ).
    • server/vite.ts — vite и vite.config импортируются динамически (dev-only), vite.config — по вычисляемому URL.
  • Как проверялось: локальная сборка образа; бандл стартует без devDeps (проверка до подключения БД); grep — 0 статических импортов vite в dist/index.js.
  • Влияние на поиск/UX: нет.
  • Инцидент и подводные камни (важно для будущих агентов):
    1. Правки Dockerfile ушли в main досрочно (попали в коммит 0.11 через git add -A) — автодеплой поднял их до завершения smoke-теста. Правило: коммитить явным списком файлов, не git add -A.
    2. Первое падение: ERR_MODULE_NOT_FOUND @vitejs/plugin-react — server/vite.ts статически импортировал vite/vite.config; раньше спасало наличие всех devDeps в образе.
    3. Второе падение: esbuild без --splitting ИНЛАЙНИТ статический import("../vite.config") в бандл и поднимает его импорты на верхний уровень — динамический импорт должен быть по вычисляемому пути (new URL(...), esbuild не анализирует).
    4. Локальная проверка node dist/index.js НЕ ловит такие ошибки, если локальный node_modules содержит devDeps — резолвится молча. Проверять grep'ом по бандлу или в чистом окружении.
    5. Даунтайм продакшена ~25 минут (краш-луп до фикса 5426c70).

[0.2] Контейнер не от root

  • Статус: ✅ done (коммит a54ec77)
  • Зачем: компрометация приложения не даёт root в контейнере.
  • Что изменено: Dockerfile — adduser -D app (uid 1001), chown -R app:app /app, USER app, HOME=/tmp, npm_config_cache=/tmp/.npm, PYTHONDONTWRITEBYTECODE=1; docker/Dockerfile.documents — аналогично (там уже был npm ci --production).
  • Как проверялось: 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), курсор <updatedAt>_<id> (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).

Регрессия поиска после 0.10/0.9 (инвариант) — ПРОЙДЕНА

  • Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch).
  • EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по tasks_org_updated_idx, Execution Time 0.806 мс — мгновенность сохранена с запасом.
  • Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает.

[0.12] getAccessibleTaskIds: кэш + инвалидация

  • Статус: ✅ done
  • Зачем: самый горячий эндпоинт перестаёт деградировать линейно (аудит, Фаза 0). Переписывание EXISTS-цепочки на чистый SQL отложено до замеров после кэша (по плану).
  • Что изменено:
    • server/utils/cache.ts — accessibleTasksCache (TTL 45 сек), ключ accessible:<userId>:<orgId>, метод invalidateSuffix, хелперы точечной (per-user) и org-wide инвалидации.
    • server/storage/system.storage.ts — кэш внутри getAccessibleTaskIds (все caller'ы получают его автоматически, код роутов не тронут); null (admin/view_all) кэшируется отдельно от miss.
    • Инвалидация проставлена в storage-слое (покрывает роуты и MCP): assignees/roles/formAccess/delegation/form visibility/updateUser appRole/createTask/updateTask assignedTo.
  • Как проверялось: npm run check чисто; npx vitest run 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user).
  • Влияние на поиск/UX: нет (форматы ответов не тронуты).
  • Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей).