Шаги 1.4 и 1.5 плана production-готовности: - SseConnectionIndex (byOrg/byUser), один heartbeat-таймер, протокол SSE не тронут - getTaskTree/getTaskParentChain: WITH RECURSIVE CTE (было 1+2N запросов) - sync initial/delta: Promise.all по формам - embedding queue/reindex: батч-предзагрузки вместо поштучных запросов - 8 новых тестов (96/96)
28 KiB
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, HEADa2b231d),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 run45/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()в автоматизациях и MCPlist_usersтоже sanitized.
- Как проверялось: ни один из 43 caller'ов не использовал исключённые поля (tsc); sync (initial + delta) теперь отдаёт пользователей без хэшей;
npm run checkчисто;npx vitest run45/45; greppasswordHashпо 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: нет.
- Инцидент и подводные камни (важно для будущих агентов):
- Правки Dockerfile ушли в main досрочно (попали в коммит 0.11 через
git add -A) — автодеплой поднял их до завершения smoke-теста. Правило: коммитить явным списком файлов, неgit add -A. - Первое падение:
ERR_MODULE_NOT_FOUND @vitejs/plugin-react—server/vite.tsстатически импортировал vite/vite.config; раньше спасало наличие всех devDeps в образе. - Второе падение: esbuild без
--splittingИНЛАЙНИТ статическийimport("../vite.config")в бандл и поднимает его импорты на верхний уровень — динамический импорт должен быть по вычисляемому пути (new URL(...), esbuild не анализирует). - Локальная проверка
node dist/index.jsНЕ ловит такие ошибки, если локальный node_modules содержит devDeps — резолвится молча. Проверять grep'ом по бандлу или в чистом окружении. - Даунтайм продакшена ~25 минут (краш-луп до фикса
5426c70).
- Правки Dockerfile ушли в main досрочно (попали в коммит 0.11 через
[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 run56/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 run65/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 (та же оговорка, что у существующих кэшей).
[0.13] Биллинг: PaymentProvider + MockPaymentProvider
- Статус: ✅ done
- Зачем: самообслуживание оплаты без superadmin; реальный эквайринг (ЮKassa/CloudPayments) позже без переписывания (решение пользователя — только mock + интерфейс).
- Что изменено:
server/billing/payment-provider.ts— интерфейс PaymentProvider + фабрика по envPAYMENT_PROVIDER(дефолт mock).server/billing/mock-provider.ts— mock: externalId mock_, webhook-подпись sha256 (timingSafeEqual), secret envMOCK_PAYMENT_SECRET.server/billing/payments.service.ts— createBillingPayment (ON CONFLICT по idempotency_key),applySucceededPayment: транзакция с атомарным guard pending→succeeded (дубли webhook невозможны), credit в billing_transactions, инкремент balance, авто-снятие billingBlocked при балансе > 0 (та же логика, что superadmin unblock).server/routes/billing-payments.routes.ts— POST/GET /api/billing/payments (billing.manage), POST /:id/confirm-test (только mock, иначе 404), публичный POST /api/billing/webhooks/:provider (401 невалидная подпись, идемпотентность).migrations/0080_billing_payments.sql+ таблица в shared/schema.ts (UNIQUE external_id/idempotency_key).client/src/pages/Billing.tsx— фикс setLocation в теле рендера (useEffect), кнопка «Пополнить» с диалогом, секция «Платежи», «Подтвердить (тест)» у pending при mock (признак paymentProvider в /api/billing/summary), текст блокировки про авто-восстановление.tests/billing-payments.test.ts— 13 тестов.
- Как проверялось: vitest 78/78 (дедуп по Idempotency-Key, повторный webhook не зачисляет дважды, изоляция организаций, 404 confirm-test при не-mock, 401 по подписи);
npm run checkчисто. - Влияние на поиск/UX: нет.
- Подводные камни: на проде PAYMENT_PROVIDER не задан → mock активен, кнопка «Подтвердить (тест)» видна админам организаций — это и есть поставка шага; при подключении реального провайдера кнопка скроется автоматически. Подключение реального провайдера: новый класс в server/billing/ + case в фабрике + env (см. отчёт в коде payment-provider.ts).
[1.1] Data-tables: фильтрация на уровне SQL
- Статус: ✅ done
- Зачем: справочники не грузят все строки/таблицы ради фильтрации в JS (Фаза 1).
- Что изменено:
server/storage/data-tables-core.storage.ts—getDataTablesWithAccessбез N+1 (2 запроса вместо 1+N); новыйgetDataTableRowsPaged(SQL: фильтрыvalues->>N ILIKE ESCAPE, поискvalues::text ILIKE, сортировка lower()+тай-брейкер position/id, LIMIT/OFFSET);getDataTableRowCounts(один GROUP BY);moveDataTableRow— siblings SQL-запросом.server/utils/data-table-rows-query.ts— escapeIlikePattern, капы limit (дефолт 100, макс 1000)/offset.server/routes/data-tables.routes.ts— GET /rows с limit/offset/search → SQL-путь{rows, total}; без параметров — прежний JS-путь (сортировка localeCompare('ru') сохранена 1:1 для текущего фронта).server/mcp.ts— list_directory_rows (flat) на SQL-пагинацию; list_directories rowCount одним запросом.
- Tree-режим сознательно не тронут: дерево строится из всех строк, пагинация неприменима.
- Как проверялось: vitest 88/88 (10 новых тестов хелперов);
npm run checkчисто. - Влияние на поиск/UX: нет (TableEditor попадает в legacy-ветку).
- Подводные камни:
/api/directories/:id/column-valuesвсё ещё грузит все строки (прерывается на 20) — кандидат на следующий заход (SELECT DISTINCT values->>N LIMIT); фронт TableEditor пока не использует SQL-пагинацию (эндпоинт готов).
[1.2] /column-values: лимиты и чистка горячего пути
- Статус: ✅ done
- Зачем: cap выборки, убрать console.log с горячего пути (Фаза 1).
- Что изменено:
server/routes/task-crud-list.routes.ts— убраны 7 debug-логов на запрос + per-task лог contract-number; параметр limit (дефолт 20, кап 1000). - Как проверялось: выборка покрыта индексом tasks_org_id_form_id_idx — новый индекс не нужен; vitest 88/88.
- Влияние на поиск/UX: нет.
[1.4] SSE: индекс соединений
- Статус: ✅ done
- Зачем: рассылка событий не перебирает все соединения; один heartbeat вместо таймера на соединение (Фаза 1).
- Что изменено:
server/utils/sse-connection-index.ts(новый класс: byId/byOrg/byUser);server/routes/shared.ts— EventBus рассылает через индекс (таргетинг org/user/user+org/broadcast с прежней tenant-семантикой), один глобальный heartbeat-таймер (unref, стоп при отсутствии соединений), убраны per-event console.log. - Протокол НЕ тронут: id:/event:/data:, буферы 500, replay по lastEventId — клиенты совместимы.
- Как проверялось: 8 новых юнит-тестов индекса; vitest 96/96;
npm run checkчисто. - Влияние на поиск/UX: нет.
- Подводные камни: живой прогон SSE (чат в двух вкладках, reconnect по lastEventId) — проверить на проде после деплоя (юнит-тесты покрывают маршрутизацию, не HTTP-поток).
[1.5] N+1: recursive CTE, Promise.all, батчи
- Статус: ✅ done
- Зачем: убрать N+1 на горячих путях (Фаза 1).
- Что изменено:
getTaskTree— один WITH RECURSIVE CTE + один IN-запрос (было 1+2N); защита от циклов (lvl<100, assembled-Set); формат результата прежний.- Новый
getTaskParentChain(CTE вверх, max 10) — MCP get_task_tree без цикла getTask. - Батч-методы: getTasksByIds, getTaskFieldValuesByTaskIds, getTaskMessagesByIds/ByTaskIds.
- sync.routes.ts — /initial и /delta: Promise.all по формам (порядок ответа сохранён).
- embedding.service.ts — processEmbeddingQueue и reindexOrganization: батч-предзагрузки (3–4 запроса на org вместо 2–3 на элемент; 2 запроса на чанк 100 задач вместо ~200).
- Как проверялось: vitest 96/96;
npm run checkчисто; caller'ы дерева (роут /tree, MCP) — формат не изменился. - Влияние на поиск/UX: нет.
- Подводные камни: raw CTE под PostgreSQL — при переименовании колонок tasks/forms обновить вручную (drizzle не проверяет); getRelatedTasks (граф task_relations) сознательно оставлен — кандидат на отдельный шаг.