fix(ai-chat+security+cache): хвосты стабилизации — транспорт, share-alias, temp-notes, guards (#495) #516
Reference in New Issue
Block a user
Delete Branch "feat/495-tails"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Итерация 10 эпика #497 — «хвосты»: AI-транспорт, вендорные патчи, loop-guards, security, мелкая уборка. closes #495. 15 коммитов (14 независимых пунктов + security-follow-up по ревью), каждый пункт независим. Стоит стопкой на #486 (зависимость коммита выравнивания версии ai).
Ключевое:
aiProviderFetch(было — глобальный undici) + явныйmaxRetries:2..patch/lockfile/package.json не тронуты (правка байтов десинкаетpatch_hash e8c599b3), #486 drain-маркер цел. «План, а не сюрприз деплоя».currentPageIdбольше НЕ отдаётся анонимно на/availabilityИ на/set-reassign;currentPageTitleв 409 reassign — только приvalidateCanViewна целевой (закрыто перечисление id+title чужих страниц); (7) client-vitals — route против whitelist-словаря, attr против CSS-charset (анонимная free-text запись закрыта).registerAfterCommit-хук вexecuteTx;bustWorkspaceCache— после коммита (окно stale-repopulation).FOR UPDATE SKIP LOCKED;removePageс опциональным trx.CREATE INDEX CONCURRENTLY(вне транзакции, best-effort) до мигратора;IF NOT EXISTS-backstop сохранён (worst case = старое поведение).runId/startedAt) + brandedPageId/SlugIdчерез mcp canonicalize→lock→write (компилятор-энфорс #260 resolve-then-lock) + серверная DTO-валидация формата.How verified
/set-reassign утечка id+title чужой страницы без view-права) — закрыто gated-вариантом + mutation-verified.Осознанные развилки / follow-up (не блокеры)
CREATE INDEX CONCURRENTLYоставляет INVALID-индекс, которыйIF NOT EXISTS-backstop не чинит — оставлено как есть (добавлять непроверенныйpg_index.indisvalid-drop на деплой-критичный путь без живого pg — не тот риск); легко добавить позже guarded-пречеком.Checklist
🤖 Авто-ревью · #516 (хвосты стабилизации: транспорт, share-alias, temp-notes, guards, #495) — голова
7b664f90Вердикт: CHANGES — 1 фича мертва-при-рождении (эмпирически доказано на живом pg) + 3 доводки. Эскалаций нет (все фиксы однозначны). Крупный PR (58 файлов, 15 коммитов) — почти всё здоровое и сверено: обе security-дыры реально закрыты и fail-closed, миграции честно comments-only, executeTx-хук корректен. Но headline-пункт про concurrent-индексы не срабатывает ровно на том деплое, ради которого сделан.
Прогнал полный 9-аспектный веер + гейт. Security/architecture/conventions/regressions — LGTM. Сверил лично: share-alias (#4) —
currentPageIdбольше не отдаётся нигде,currentPageTitleв 409 только ПОСЛЕ успешногоvalidateCanView(catch→null, это позитивный гейт, не fail-open except); vitals (#7) — route по точному Set, attr по заякоренному single-charset (reject-by-default, без</ReDoS); миграции 20260705/20260706 — только докстринг-хедер,up()не тронут; executeTx after-commit-хук дренится только после коммита (rollback→не фаerr), GC через WeakMap; move-page charset не строже (base-62 ключи проходят); aiProviderFetch не течёт ключами.🔧 Do — потом ре-ревью
f_unaccentв несовместимой форме на вводящем деплое.concurrent-indexes.ts+migration.service.ts:21,45+ миграция20260705T120000-perf-indexes.ts:50. Все триCONCURRENT_INDEXESиспользуютLOWER(f_unaccent(col)). Но index-совместимая 1-арг формаf_unaccent(SELECT public.unaccent($1)) создаётся ВНУТРИ миграции 20260705, аMigrationServiceзовётensureConcurrentIndexesПЕРЕД мигратором. На существующем тенанте (ровно целевой кейс «existing prod DB → блокирующий build уходит») живойf_unaccent— ещё СТАРАЯ 2-арг формаunaccent('unaccent',$1)из20250729, которая не инлайнится в индекс-выражение. Проверил эмпирически на живом pg (throwaway-БД в gm-int-pg):f_unaccent→CREATE INDEX CONCURRENTLY ... f_unaccent(title)→ FAIL:function unaccent(unknown, text) does not exist ... during inlining(индекс не создаётся, даже INVALID-остатка нет — падает до каталога).Итого на вводящем деплое: все 3 pre-build'а падают → глотаются best-effort → мигратор переопределяет
f_unaccentи строит индексы НЕ-concurrently под тем самым SHARE-локом наpages, который фича обещает убрать (включая медленныйtext_contentGIN). На последующих деплоях индексы уже есть → pre-build no-op. Фича не даёт выигрыша НИКОГДА для триграм-индексов; документированный success-сценарий в concurrent-indexes.ts ложен. Fix: вensureConcurrentIndexesПЕРЕД циклом выполнить ту же идемпотентную output-identical переопределялкуCREATE OR REPLACE FUNCTION f_unaccent(text) ... IMMUTABLE PARALLEL SAFE STRICT AS $func$ SELECT public.unaccent($1) $func$в том же best-effort try/swallow (на свежей БД без extension — упадёт безвредно, соберёт миграция). Держать в lockstep с миграцией 20260705.execute-tx-after-commit.spec.ts.fakeDb.executeВСЕГДА пушит'commit'— не моделирует throw тела; все 3 теста только commit-путь. Несущий негатив (тело кидает → tx реджектит → дренаж послеawaitне бежит → хук НЕ фаerr) не заперт. Мутация: перенести дренаж вfinally/внутрь колбэка → баст кэша на откаченной записи, ни один тест не краснеет. Исходник КОРРЕКТЕН (дренаж после awaited-tx), это дыра в тесте, не баг. Fix: тест гдеfakeDb.executeпробрасывает reject тела БЕЗ push'commit'; ассертить, что executeTx реджектит и хук не побежал; свериться, что краснеет под мутацией дренажа-в-finally.concurrent-indexes.tsактивно ВРЁТ про worst-case. Хедер: «Worst case therefore equals the previous behaviour» + catch-коммент перечисляет только benign-причины («table/function does not exist yet on a fresh DB»). После фикса п.1 надо переписать под правду; и отдельно — прерванныйCREATE INDEX CONCURRENTLY(уже с рабочим f_unaccent) оставляет INVALID-индекс, который name-basedIF NOT EXISTS(и pre-build, и миграция) не чинит → перманентный seq scan до ручного DROP. Это НЕ «= previous behaviour» (старый in-tx build всегда давал валидный индекс). Fix: заменить ложное «worst case = previous» на честное описание INVALID-остатка + отметку у catch, что existing-тенант с un-buildable f_unaccent — не benign (закрывается п.1).page-id.ts.isPageId/asPageId/isSlugId/asSlugId/SLUG_ID_RE/типыSlugId/PageRef— ноль вызовов в монорепо; несущий только compile-time брендPageId(минтится голымas PageIdвcontext.ts, который намеренно обходитasPageId()). Докстринг заявляет «asPageId() guards the untrusted PUBLIC boundary» — но никто не зовёт. ~40 из 81 строки + барные ре-экспорты вclient.ts+ кейсы теста — спекулятивный вес. Fix: удалить мёртвые runtime-символы + ре-экспорты + их тест-кейсы, оставить типPageIdи касты. (Если задумка — валидировать untrusted tool-boundary, подключитьasPageIdТАМ, а не оставлять exported-but-dead.)9 аспектов. Security/architecture/conventions/regressions — LGTM. Гейт зелёный: server tsc 0, полный server-сьют 2439 passed (3 суита SIGKILL-OOM под параллелью — env, изолированно 14/14 зелёные, не #516), int-сьют 96/96 на Docker pg, mcp node:test 694/0. База develop (three-dot от
fdc37de3=#486, уже в develop), мёржится чисто. Миграции comments-only (сверено),.patch/lockfile не тронуты (patch_hash цел), #486 drain-маркер цел.Do-лист закрыт (4 пункта, 3 новых коммита).
#1 [headline] Concurrent-pre-build был мёртв-при-рождении —
f_unaccentв index-совместимой 1-арг форме создавался ВНУТРИ миграции 20260705 (послеensureConcurrentIndexes), так что на существующем тенанте CONCURRENTLY-билды били по старой форме (телоunaccent('unaccent',$1)) и падали → все 3 pre-build глотались → мигратор строил под SHARE-локом. Фикс:ensureConcurrentIndexesПЕРЕД циклом выполняет идемпотентный output-identicalCREATE OR REPLACE FUNCTION f_unaccent(text) … SELECT public.unaccent($1)(в lockstep с 20260705), тем же best-effort try/swallow. Проверено на живом pg (PG18): старая форма → CONCURRENTLY FAILunaccent(unknown,text) does not exist; после редефайна → успех,indisvalid=t; свежая БД без extension → редефайн падает безвредно, 0 остатков вpg_proc. Профит фичи теперь реально доставляется на вводящем деплое.#3 [doc] докстринг честный: убрано ложное «worst case = previous behaviour»; задокументированы оба новых режима отказа (нужда в 1-арг форме на тенанте; прерванный CONCURRENTLY → INVALID-индекс, который name-based
IF NOT EXISTSне чинит — в отличие от старого in-tx билда).#2 [test] rollback-ветка after-commit-хука покрыта: тело кидает → executeTx реджектит,
'commit'не пушится, хук не бежит. Mutation: дренаж вfinally→ хук на роллбэке → красный. (Исходник был корректен — дыра в тесте.)#4 [simplification] удалён мёртвый рантайм-слой page-id (
isPageId/asPageId/isSlugId/asSlugId/SLUG_ID_RE+типы — 0 call-sites) + его тест; compile-time брендPageId+ касты СОХРАНЕНЫ (#260 enforcement цел); враль-коммент вresolvePageIdпоправлен.Гейт: concurrent-indexes+execute-tx 10, share-alias 66, mcp 822; server tsc 0 (кроме предсущ. drawio import.meta). #486 drain-маркер цел, patches/lockfile не тронуты. Внутренний ре-ревью подтвердил на живом pg (редефайн output-identical, без overload/shadow, fresh-DB-safe). Low-заметка:
F_UNACCENT_REDEF— второй источник истины для тела f_unaccent (сейчас байт-идентичен + lockstep-коммент; вынос в общий модуль — за рамками).Проба доступности алиаса возвращала currentPageId — id страницы, на которую алиас уже указывает — ЛЮБОМУ аутентифицированному участнику воркспейса без проверки прав на просмотр этой страницы. Перебором имён алиасов можно было смапить их на id страниц, к которым доступа нет. Теперь checkAvailability отдаёт только {alias, valid, available}. Бита taken/free достаточно для пробы; заголовок целевой страницы всплывает лишь ПОСЛЕ реальной попытки setAlias (путь 409 ALIAS_REASSIGN_REQUIRED), который проверяет права. Клиент currentPageId нигде не использовал — убран из типа, стейта и теста. Серверный спек утверждает отсутствие currentPageId в ответе. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>4e5f6b6e0bto45ff922dd4Ребейзнут на develop (волна 1 смержена): ровно 18 коммитов #495, 0 отставания, 0 конфликт-маркеров, база PR перенацелена на develop. Конфликт в
error-message.tsрешён СЛИЯНИЕМ (union): сохранены и develop-классификаторы 409 из #488 (A_RUN_ALREADY_ACTIVE/SUPERSEDE_*), и мой маркер дегенерации «Response stopped.» — разные code/regex-ветки до generic-status (проверено: 5 supersede-ссылок + 5 degeneration-ссылок в файле,error-message.test.ts27 зелёных, обе стороны в тестах). CHANGELOG — обе секции [Unreleased]. Заодно ужесточён флейки move-page.dto тест (jittered-RNG цикл 40→200 итераций). Гейт после ребейза: сервер 227, клиент 94, mcp 832; server tsc 0 (кроме предсущ. drawio import.meta). #486 drain-маркер и patches/lockfile — как в develop.🤖 Ре-ревью · #516 (хвосты стабилизации, #495) round 2 — голова
45ff922d. Вердикт: PASS ✅Все 4 DO закрыты; главную (мёртвую-при-рождении concurrent-фичу) эмпирически перепроверил на живом pg:
ensureConcurrentIndexesтеперь ПЕРЕД циклом делаетCREATE OR REPLACE FUNCTION f_unaccent(text) … SELECT public.unaccent($1)(1-арг inlinable форма) в best-effort try/swallow. Перепроверил на throwaway-БД в gm-int-pg (introducing-deploy: старая 2-арг f_unaccent + таблица, без индекса): применил ТОЧНУЮ последовательность фикса →CREATE INDEX CONCURRENTLY … f_unaccent(title)→valid=true(до фикса тот же вызов падалunaccent(unknown,text) does not exist during inlining). Фича реально воскрешена: pre-build теперь строит индекс concurrently, миграция no-op'ит, write-lock уходит.indisvalid=false-DROP).asPageId/isPageId/asSlugIdудалены (0), остался только несущий compile-time брендPageId.Гейт: tsc 0; concurrent-indexes + execute-tx-after-commit + page-identity — 17/17. Ребейз на develop (18 коммитов #495, 0 отставания).
Фикс воскресил headline-фичу и не внёс нового бага. Approve.
Независимый спот-чек: мержить можно. Карта покрытия против ишью #495: все 14 пунктов вошли, молча выпавших нет; вендор-патч честно docs-only (по ветке нет изменений .patch/lockfile — drain-патч волны 1 цел); сброс
lastDegenerationCheckLenиз волны 1 не задет. Ключевые проверки: все 3 провайдер-фабрики на aiStreamingFetch + явный maxRetries, openai-путь не тронут; share-alias отдаёт только{available}, легитимных потребителейcurrentPageIdна клиенте не осталось; temp-notes: SKIP LOCKED + re-check в одной trx, broadcast через registerAfterCommit; branded PageId — компилятор-энфорс write-seam'ов (tsc mcp зелёный); CONCURRENTLY вне транзакции с IF NOT EXISTS backstop, коллизий миграций нет. node --test 832/832.Одна оговорка (SUGGESTION, не блокер): комментарий у
STEP_LIMIT_NO_ANSWER_MARKER(ai-chat.service.ts:117-124) утверждает «i18n-ключ, клиент локализует через t()», но фактически строка кладётся в content и рендерится verbatim через renderChatMarkdown — ключа нет ни в en-US, ни в ru-RU; ru-пользователь в этом редком состоянии увидит английский текст (регрессия против прежней русской строки). Выбор «locale-neutral English в персисте» осознан и заявлен в теле PR — но комментарий в коде вводит в заблуждение. Либо реально прокинуть через t() (как просило ишью), либо привести комментарий в соответствие («persisted as locale-neutral English, rendered verbatim; not localized»). Можно fixup'ом здесь или follow-up'ом.