В PWA накапливались десятки конфликтов «/api/devices/active-chat»: эфемерные
presence-вызовы (активный чат, heartbeat) при сетевых сбоях ставились в очередь,
при реплее ловили 4xx и становились конфликтами, требующими ручной разборки.
Реплеить их бессмысленно — они устаревают мгновенно.
- isQueueable: /api/devices/active-chat и /api/devices/heartbeat больше не
ставятся в очередь (как read receipts);
- isSafeToDrop: те же URL молча дропаются при 4xx;
- уже помеченные конфликтами presence-записи авто-синк дропает молча —
накопленные десятки очистятся при следующей синхронизации.
Пункты 1–2 плана де-дубликации чатов — больше не нужно «чинить и там и там»:
- components/chat/ChatComposer.tsx: textarea + меню трёх точек (Прикрепить файл/
Создать опрос) + кнопка отправки, Enter-отправка (в PWA — только кнопкой),
paste/drag&drop файлов, спиннер отправки; специфика клавиш родителя
(упоминания, Escape) через onKeyDown + defaultPrevented-протокол;
- components/chat/useChatScroll.ts: pin к низу при открытии (useLayoutEffect до
paint), автоскролл у низа/своё, тихая доставка на скрытой вкладке, счётчик
«↓ N новых», pin при возврате на вкладку;
- TaskChat и useChatController/Chat.tsx переведены на оба; удалены дубли
(~200 строк): свои scrollToBottom/atBottomRef/lastSeenLengthRef/эффекты,
свои формы композера, drag&drop-стейты.
Кнопки «Прикрепить файл» и «Создать опрос» в мессенджере спрятаны за иконку
MoreVertical (DropdownMenu) — единый вид композера в обоих чатах (в чате задачи
сделано ранее, f6e3e19).
- JsComponentRenderer: ctx.libs.charts (recharts) — графики в кастомных страницах
и JS-вкладках без redeploy; справка get_js_coding_reference обновлена
- Внешние БД (миграция 0088): вкладка «Настройки → Внешние БД» — реестр подключений
(пароль AES-256-GCM, env EXTERNAL_DB_SECRET, по API не отдаётся), тест подключения,
allowlist таблиц на подключение
- Read-only шлюз POST /api/external-db/:id/query (finance.manage): только SELECT,
без мульти-выражений, обёртка с LIMIT 5000, statement_timeout 10с, проверка таблиц
по allowlist, аудит в external_db_query_log (кто/SQL/строки/мс)
- Назначение: ИИ-агенты строят отчёты и виджеты поверх витрин (напр. НДС 1С) без
новых серверных эндпоинтов и пересборки
Клиентская часть гонки (вариант B): Web Locks API (navigator.locks) —
кросс-вкладочный мьютекс iistwin-offline-sync-replay на replay-проход в
useOfflineSync: первая вкладка под локом реплеит и удаляет записи, остальные
видят пустую очередь. Fallback для сред без Web Locks — старое поведение
(дубли гасит серверная идемпотентность). Race-тест: без locks → 2 POST,
с locks → 1 POST для одной записи очереди.
Мессенджер (conv_messages) — та же защита, что у task_messages:
- conversation_messages.client_message_id + partial unique (миграция 0087);
- POST /api/messenger/conversations/:id/messages: повтор с тем же
clientMessageId → существующее сообщение без insert и без SSE/уведомлений,
гонка insert'ов ловится по 23505;
- useChatController шлёт crypto.randomUUID(), attachment-реплей мессенджера
тоже использует id записи очереди.
Первопричина дублей (3 одинаковых сообщения в задаче 1988): живой POST сорвался
→ сообщение попало в общую IndexedDB-очередь → background-sync разослал
TRIGGER_SYNC всем вкладкам → каждая вкладка реплейнула одну и ту же запись
(мьютекс _syncLock модульный, кросс-вкладочной координации нет). Гонка
воспроизведена тестом tests/offline-queue-multitab-race.test.tsx (1 запись →
2 POST для двух вкладок).
Вариант C — серверная идемпотентность:
- task_messages.client_message_id + partial unique index (миграция 0086);
- sendTaskMessage: повтор с тем же clientMessageId возвращает существующее
сообщение БЕЗ insert и БЕЗ side-эффектов (уведомления/SSE/вебхуки не дублируются);
гонка insert'ов ловится по 23505 → fallback на select;
- клиент TaskChat шлёт crypto.randomUUID() в каждом сообщении; при офлайн-постановке
тело с ключом сохраняется, все реплеи идут с одним ключом;
- attachment-реплей: clientMessageId = id записи очереди (стабилен между реплеями);
- юнит-тесты tests/task-message-idempotency.test.ts (3: быстрый путь, гонка 23505,
проброс прочих ошибок).
Причина «поля профиля не отображаются»: /api/users/list обслуживает СТАРЫЙ
обработчик в auth.users.routes.ts (зарегистрирован раньше дубля handleListUsers
в user.routes.ts — проверено: в ответе нет total/pageSize). includeFieldValues
добавлен в оба, но реальный — теперь тоже отдаёт fieldValues.
Плюс overflow-x-auto на контейнере таблицы — прокрутка при переполнении колонок.
Реестр /users:
- кнопка «Колонки» — чекбоксы базовых колонок и кастомных полей профиля
(Оценка, Средняя оценка и др.), скрытые ключи в localStorage;
- /api/users/list?includeFieldValues=1 — значения полей текущей страницы;
- поля типа history-number (Оценка) кликабельны — открывают график истории.
График истории (FieldHistoryDialog):
- точки красные и крупные (на тёмном фоне дефолтные не видны), линия primary;
- клик по точке — под графиком список оценок за бакет со ссылками на задачи
(название задачи → /forms/<formId>/tasks/<id>);
- API field-history возвращает entries (value, taskId, taskTitle, formId, bucket).
useCallback для ChatSection стоял после раннего return (if (!task)) —
в рендерах без задачи хук не вызывался → «Rendered more hooks than during
the previous render». Правильное решение: компонент на module level с пропом
taskId (стабильный тип + нет зависимости от хуков).
- TaskDetail: ChatSection объявлен inline в теле рендера → новый тип компонента
на каждый рендер → React перемонтировал весь чат при ЛЮБОМ ре-рендере TaskDetail
(SSE-событие, refetch задачи, возврат на вкладку): скролл сбрасывался вверх,
пользователь видел «старые сообщения → прыжок к последним». Фикс: useCallback
со стабильной идентичностью (тот же паттерн, что у мобильного accordion).
- TaskChat/useChatController: скролл к низу при открытии чата — useLayoutEffect
СИНХРОННО ДО paint (первый кадр уже у низа) + докрутка первого кадра, когда
сообщения пришли после монтирования без кэша.
- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с
- Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата)
- Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity
- SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
- TaskChat: atBottomRef-гвард (scrollHeight-scrollTop-clientHeight<80) — чужие
сообщения больше не дёргают чат вниз, когда пользователь читает историю;
свои сообщения и смена задачи скроллят как раньше
- ChatAttachments: img w-180 h-180 object-cover вместо max-* — высота строки
не меняется при догрузке, measureElement виртуализатора не дёргает список
- упоминания: голый @ открывает список сразу; текстовый триггер от 3 букв;
сервер ищет ILIKE по имени/отчеству/фамилии/email и отдаёт всех при пустом q;
починен мёртвый fallback-резолв (Response без .json())
- groupByDate/flattenGroupedMessages обёрнуты в useMemo
- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
выдаёт новую пару от successor вместо deny; cookie больше не стираются при
stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
(transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
Каждая точка ingest (~1/сек) broadcast'илась SSE всем клиентам орг., клиент
на каждое событие делал invalidateQueries без throttle → 2+ GET/сек → исчерпание
per-user бакета apiLimiter (1500/15мин) → все API устройства 429 (формы не грузились).
Модель теперь как у нормальных трекеров: точки пишутся в БД, карта читает по интервалу.
+4 unit-теста троттла (server/gps/sse-throttle.ts)
- шапка: [&_th]:bg-background вместо полупрозрачного bg-muted/50 — иначе сквозь
sticky-шапку проступают строки и она нечитаема (в plain-варианте фона не было вовсе)
- ресайз: table-fixed + w-full таблицы + w-max wrapper давали циклическую зависимость
ширин — колонки без заданной ширины схлопывались до нуля при первом drag.
В auto-раскладке <colgroup> задаёт предпочтительную ширину без схлопывания
check/build чисто, vitest 129/129
- ui/table: пропс wrapperClassName (twMerge), в реестре wrapper = w-max min-w-full
overflow-visible — переполнение по ширине всплывает в контейнер реестра,
скроллбар оказывается внизу видимой области, а не под всей таблицей
- TableHeader sticky top-0 (фон tr для plain-варианта скина)
- drag правого края шапки меняет ширину колонки (table-fixed + colgroup,
min 80 / max 1200 px), двойной клик — сброс; ширины хранятся в localStorage
per-form (registry-colwidths-<formId>) — у каждого пользователя свои
- настройка «Строк в ячейке» (Авто / 1..8) в диалоге настроек реестра:
CSS line-clamp на текстовых ячейках, хранится в registry-rowlines-<formId>;
оценка высоты виртуализатора ниже в однострочном режиме
check/build чисто, vitest 129/129
На мобильном (<768px) main#content был блочным: flex-1 обёртки PageTransition
работал вхолостую, высота страницы росла по контенту, весь скролл уходил в main,
а скроллбар широкой таблицы реестра оказывался внизу недостижимой высоты.
Десктопный main уже был flex flex-col (Sidebar.tsx:1687).