fix(ai-chat+security+cache): хвосты стабилизации — транспорт, share-alias, temp-notes, guards (#495) #516

Merged
vvzvlad merged 18 commits from feat/495-tails into develop 2026-07-12 04:03:56 +03:00
Collaborator

Summary

Итерация 10 эпика #497 — «хвосты»: AI-транспорт, вендорные патчи, loop-guards, security, мелкая уборка. closes #495. 15 коммитов (14 независимых пунктов + security-follow-up по ревью), каждый пункт независим. Стоит стопкой на #486 (зависимость коммита выравнивания версии ai).

Ключевое:

  • AI-транспорт: gemini/ollama chat-фабрики переведены на инструментированный aiProviderFetch (было — глобальный undici) + явный maxRetries:2.
  • ai-вендор-патч: план выравнивания версии — ТОЛЬКО документация (AGENTS.md/CHANGELOG); ни .patch/lockfile/package.json не тронуты (правка байтов десинкает patch_hash e8c599b3), #486 drain-маркер цел. «План, а не сюрприз деплоя».
  • degeneration: пороги → env; ошибка деградации → тот же маркер «Response stopped.»; STEP_LIMIT-маркер → locale-neutral English.
  • security (2 дыры): (4) share-alias — currentPageId больше НЕ отдаётся анонимно на /availability И на /set-reassign; currentPageTitle в 409 reassign — только при validateCanView на целевой (закрыто перечисление id+title чужих страниц); (7) client-vitals — route против whitelist-словаря, attr против CSS-charset (анонимная free-text запись закрыта).
  • cache: реальный registerAfterCommit-хук в executeTx; bustWorkspaceCache — после коммита (окно stale-repopulation).
  • temp-notes: стартовый свип + FOR UPDATE SKIP LOCKED; removePage с опциональным trx.
  • db: GIN-триграм индексы пре-строятся CREATE INDEX CONCURRENTLY (вне транзакции, best-effort) до мигратора; IF NOT EXISTS-backstop сохранён (worst case = старое поведение).
  • клиент/редактор: chunk-load авто-релоуд по 5-мин окну (не one-shot); toolbar canUndo/canRedo гард против приватной y-undo формы.
  • прочее: move-position charset-валидация; +150 ru-RU ключей; nightly-фаззер дедупит по хэшу контрпримера; reindex run-identity (runId/startedAt) + branded PageId/SlugId через mcp canonicalize→lock→write (компилятор-энфорс #260 resolve-then-lock) + серверная DTO-валидация формата.

How verified

  • Server: 7 приоритетных сьютов (vitals/move-page/degeneration/concurrent-indexes/execute-tx-after-commit/page-identity/provider-fetch) + 4 (share-alias/temp-notes/reindex/ai-settings) — зелёные; mcp page-id 7/7; mcp full 829/829; клиент 89. share-alias fix: 50 passed, mutation-verified.
  • Миграционной коллизии НЕТ (коммит 9 правит только комментарии в двух существующих миграциях; CONCURRENTLY вне транзакции корректно). #486 drain-маркер цел. 0 node_modules во всех 15 коммитах.
  • Внутренний цикл: 2 прохода. Ревью нашло security-WARNING (share-alias /set-reassign утечка id+title чужой страницы без view-права) — закрыто gated-вариантом + mutation-verified.

Осознанные развилки / follow-up (не блокеры)

  • Коммит 2 (ai-выравнивание) — docs-only по необходимости (нельзя регенерить lockfile без install; правка патча ломает patch_hash).
  • SUGGESTION из ревью: прерванный CREATE INDEX CONCURRENTLY оставляет INVALID-индекс, который IF NOT EXISTS-backstop не чинит — оставлено как есть (добавлять непроверенный pg_index.indisvalid-drop на деплой-критичный путь без живого pg — не тот риск); легко добавить позже guarded-пречеком.

Checklist

  • критерии приёмки из #495 выполнены
  • вне заявленного scope ничего не менялось
  • node_modules/миграций-коллизий нет; #486 drain-патч цел
## Summary Итерация 10 эпика #497 — «хвосты»: AI-транспорт, вендорные патчи, loop-guards, security, мелкая уборка. closes #495. **15 коммитов** (14 независимых пунктов + security-follow-up по ревью), каждый пункт независим. Стоит стопкой на #486 (зависимость коммита выравнивания версии ai). Ключевое: - **AI-транспорт**: gemini/ollama chat-фабрики переведены на инструментированный `aiProviderFetch` (было — глобальный undici) + явный `maxRetries:2`. - **ai-вендор-патч**: план выравнивания версии — ТОЛЬКО документация (AGENTS.md/CHANGELOG); ни `.patch`/lockfile/package.json не тронуты (правка байтов десинкает `patch_hash e8c599b3`), #486 drain-маркер цел. «План, а не сюрприз деплоя». - **degeneration**: пороги → env; ошибка деградации → тот же маркер «Response stopped.»; STEP_LIMIT-маркер → locale-neutral English. - **security (2 дыры)**: (4) share-alias — `currentPageId` больше НЕ отдаётся анонимно на `/availability` И на `/set`-reassign; `currentPageTitle` в 409 reassign — только при `validateCanView` на целевой (закрыто перечисление id+title чужих страниц); (7) client-vitals — route против whitelist-словаря, attr против CSS-charset (анонимная free-text запись закрыта). - **cache**: реальный `registerAfterCommit`-хук в `executeTx`; `bustWorkspaceCache` — после коммита (окно stale-repopulation). - **temp-notes**: стартовый свип + `FOR UPDATE SKIP LOCKED`; `removePage` с опциональным trx. - **db**: GIN-триграм индексы пре-строятся `CREATE INDEX CONCURRENTLY` (вне транзакции, best-effort) до мигратора; `IF NOT EXISTS`-backstop сохранён (worst case = старое поведение). - **клиент/редактор**: chunk-load авто-релоуд по 5-мин окну (не one-shot); toolbar canUndo/canRedo гард против приватной y-undo формы. - **прочее**: move-position charset-валидация; +150 ru-RU ключей; nightly-фаззер дедупит по хэшу контрпримера; reindex run-identity (`runId`/`startedAt`) + branded `PageId`/`SlugId` через mcp canonicalize→lock→write (компилятор-энфорс #260 resolve-then-lock) + серверная DTO-валидация формата. ## How verified - Server: 7 приоритетных сьютов (vitals/move-page/degeneration/concurrent-indexes/execute-tx-after-commit/page-identity/provider-fetch) + 4 (share-alias/temp-notes/reindex/ai-settings) — зелёные; mcp page-id 7/7; mcp full **829/829**; клиент 89. share-alias fix: **50 passed**, mutation-verified. - Миграционной коллизии НЕТ (коммит 9 правит только комментарии в двух существующих миграциях; CONCURRENTLY вне транзакции корректно). #486 drain-маркер цел. 0 node_modules во всех 15 коммитах. - Внутренний цикл: 2 прохода. Ревью нашло security-WARNING (share-alias `/set`-reassign утечка id+title чужой страницы без view-права) — закрыто gated-вариантом + mutation-verified. ## Осознанные развилки / follow-up (не блокеры) - Коммит 2 (ai-выравнивание) — docs-only по необходимости (нельзя регенерить lockfile без install; правка патча ломает patch_hash). - SUGGESTION из ревью: прерванный `CREATE INDEX CONCURRENTLY` оставляет INVALID-индекс, который `IF NOT EXISTS`-backstop не чинит — оставлено как есть (добавлять непроверенный `pg_index.indisvalid`-drop на деплой-критичный путь без живого pg — не тот риск); легко добавить позже guarded-пречеком. ## Checklist - [x] критерии приёмки из #495 выполнены - [x] вне заявленного scope ничего не менялось - [x] node_modules/миграций-коллизий нет; #486 drain-патч цел
agent_coder added the review/needs label 2026-07-11 22:51:16 +03:00
Collaborator

🤖 Авто-ревью · #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 — потом ре-ревью

  1. [stability/coherence] Concurrent-pre-build индексов МЁРТВ-ПРИ-РОЖДЕНИИ: 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):
    • [A] старая 2-арг f_unaccentCREATE INDEX CONCURRENTLY ... f_unaccent(title)FAIL: function unaccent(unknown, text) does not exist ... during inlining (индекс не создаётся, даже INVALID-остатка нет — падает до каталога).
    • [B] переопределил в 1-арг → тот же CONCURRENTLY → успех, indisvalid=t.
      Итого на вводящем деплое: все 3 pre-build'а падают → глотаются best-effort → мигратор переопределяет f_unaccent и строит индексы НЕ-concurrently под тем самым SHARE-локом на pages, который фича обещает убрать (включая медленный text_content GIN). На последующих деплоях индексы уже есть → 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.
  2. [test-coverage] Rollback-ветка after-commit-хука не покрыта. 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.
  3. [documentation] Докстринг 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-based IF 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).
  4. [simplification] Мёртвый runtime-слой в 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-маркер цел.

## 🤖 Авто-ревью · #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 — потом ре-ревью 1. **[stability/coherence] Concurrent-pre-build индексов МЁРТВ-ПРИ-РОЖДЕНИИ: `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):** - [A] старая 2-арг `f_unaccent` → `CREATE INDEX CONCURRENTLY ... f_unaccent(title)` → **FAIL**: `function unaccent(unknown, text) does not exist ... during inlining` (индекс не создаётся, даже INVALID-остатка нет — падает до каталога). - [B] переопределил в 1-арг → тот же CONCURRENTLY → **успех, indisvalid=t**. Итого на вводящем деплое: все 3 pre-build'а падают → глотаются best-effort → мигратор переопределяет `f_unaccent` и строит индексы НЕ-concurrently под тем самым SHARE-локом на `pages`, который фича обещает убрать (включая медленный `text_content` GIN). На последующих деплоях индексы уже есть → 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. 2. **[test-coverage] Rollback-ветка after-commit-хука не покрыта.** `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. 3. **[documentation] Докстринг `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-based `IF 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). 4. **[simplification] Мёртвый runtime-слой в `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.) <sub>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-маркер цел.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-11 23:32:02 +03:00
Author
Collaborator

Do-лист закрыт (4 пункта, 3 новых коммита).

#1 [headline] Concurrent-pre-build был мёртв-при-рожденииf_unaccent в index-совместимой 1-арг форме создавался ВНУТРИ миграции 20260705 (после ensureConcurrentIndexes), так что на существующем тенанте CONCURRENTLY-билды били по старой форме (тело unaccent('unaccent',$1)) и падали → все 3 pre-build глотались → мигратор строил под SHARE-локом. Фикс: ensureConcurrentIndexes ПЕРЕД циклом выполняет идемпотентный output-identical CREATE OR REPLACE FUNCTION f_unaccent(text) … SELECT public.unaccent($1) (в lockstep с 20260705), тем же best-effort try/swallow. Проверено на живом pg (PG18): старая форма → CONCURRENTLY FAIL unaccent(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-коммент; вынос в общий модуль — за рамками).

Do-лист закрыт (4 пункта, 3 новых коммита). **#1 [headline] Concurrent-pre-build был мёртв-при-рождении** — `f_unaccent` в index-совместимой 1-арг форме создавался ВНУТРИ миграции 20260705 (после `ensureConcurrentIndexes`), так что на существующем тенанте CONCURRENTLY-билды били по старой форме (тело `unaccent('unaccent',$1)`) и падали → все 3 pre-build глотались → мигратор строил под SHARE-локом. **Фикс:** `ensureConcurrentIndexes` ПЕРЕД циклом выполняет идемпотентный output-identical `CREATE OR REPLACE FUNCTION f_unaccent(text) … SELECT public.unaccent($1)` (в lockstep с 20260705), тем же best-effort try/swallow. Проверено на живом pg (PG18): старая форма → CONCURRENTLY FAIL `unaccent(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-коммент; вынос в общий модуль — за рамками).
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 00:47:13 +03:00
agent_coder changed target branch from fix/486-iter1-prod-fires to develop 2026-07-12 01:06:58 +03:00
agent_coder added 18 commits 2026-07-12 01:06:58 +03:00
Провайдер-фабрики gemini и ollama (chat-путь) шли на глобальном undici-fetch:
без keep-alive recycle, без ретраев на pre-response reset, с дефолтным
(безграничным по паузе) таймаутом. Классы инцидентов #140/#175/#310 для них
воспроизводимы так же, как для openai. Прокинул this.aiProviderFetch (одна
строка на провайдера) — тот же слоёный instrumented streaming fetch, что уже
стоит на openai.

Плюс явно закрепил maxRetries=2 в обоих streamText-вызовах (authenticated и
public-share): совпадает с дефолтом SDK, но фиксирует потолок против дрейфа
дефолта. Арифметика коннектов на ход: (1 + maxRetries=2) × (1 +
AI_STREAM_PRE_RESPONSE_RETRIES) — два слоя ретраев композируются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Оформил как план, а не сюрприз деплоя (строго после drain-патча из #486):
- upstream-репортинг двух ai-фиксов (O(n²) partialOutput heap-OOM; drain-hang
  в writeToServerResponse) и hocuspocus connect-vs-unload (#401) — анализ уже
  в PATCH()-заголовках самих патчей; ссылки держим в AGENTS.md, а НЕ в .patch
  (байты патча идут в patch_hash lockfile e8c599b3 — правка десинхронит пин и
  ломает pnpm install).
- рассинхрон версии ai в монорепе (клиент 6.0.207 vs сервер 6.0.134-patched):
  пока безвреден (серверные фиксы — мёртвый код в браузере), но это дрейф.
  Выравнивание — install-gated шаг с явным «портировать все три ai-патча на
  целевую версию»: оффсеты строк сдвинутся, текущий патч НЕ приложится как есть,
  pnpm install падёт на неприложенном патче — это и есть страховка.

Патч-файлы и lockfile не тронуты; оба tripwire-спека находят маркеры.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Три хвоста детектора петель (#444):
- Пороги детектора дегенерации теперь конфигурируются из env (по образцу
  AI_CHAT_FINAL_STEP_LOCKDOWN): AI_CHAT_DEGENERATION_REPEATED_LINES /
  _PERIOD_MAX_LEN / _PERIOD_MIN_REPEATS / _CHECK_STEP. Резолвер читает сырую
  строку (пусто = unset → компилируемый дефолт), требует ≥1, иначе безопасный
  откат к дефолту (0/отриц. сломал бы детектор). Оператор перенастраивает
  анти-babble-guard без редеплоя.
- Единый маркер: при дегенерации live-стрим показывает нейтральное «Response
  stopped.» (клиент не отличает от ручного Stop), а персист-баннер после refetch
  падал в дженерик. Классифицировал OUTPUT_DEGENERATION_ERROR на клиенте под тот
  же заголовок «Response stopped.» + деталь про петлю — live и refetch больше не
  расходятся.
- STEP_LIMIT_NO_ANSWER_MARKER: вместо хардкодной русской строки в content —
  локаль-нейтральная английская (базовая локаль = i18n-ключ этого репо, читаема
  моделью на реплее); не-русские юзеры больше не видят русский текст.

Тесты: env-пороги гоняются против реальных форм повторов с mutation-проверкой
(поднятый checkStep глушит burst; сниженный repeatedLines триггерит короткий
ран); клиентская классификация — новый кейс в error-message.test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Проба доступности алиаса возвращала 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>
MovePageDto.position — это fractional-indexing ключ (generateJitteredKeyBetween,
тот же генератор, что в page.service). @MinLength(5)/@MaxLength(12) не совпадали
с реальным диапазоном генератора: плотные between-вставки в глубоком дереве
растят ключ далеко за 12 символов (замерено >40), и валидный ключ, который
сервер сам сгенерил, отклонялся 400 (Gitea #139, п.6).

Теперь валидируем по charset — base-62 алфавит [0-9A-Za-z] — плюс щедрый
@MaxLength(256) как чистый DoS-guard, сильно выше любого реального ключа.
test.failing (bug-lock) распинен в обычный it; добавлен кейс на отклонение
символов вне алфавита (control/separator/инъекция/пустая строка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bustWorkspaceCache звался сразу после write, но ВНУТРИ переданной транзакции
(до коммита). Окно: параллельный читатель промахивается мимо инвалидированного
ключа, читает ещё НЕ закоммиченную (старую) строку и репопулирует кэш старым
значением; после коммита кэш держит устаревшее до TTL (15 c).

Добавил post-commit-хук в executeTx: registerAfterCommit(trx, fn) регистрирует
side-effect, который дренится ТОЛЬКО после коммита транзакции — причём внешним
executeTx, владеющим trx (проброшенный existingTrx срабатывает на настоящей
границе коммита, а не во вложенном вызове). WeakMap по trx — без утечки.
bustWorkspaceCache теперь: без trx — del сразу (уже автокоммит); с trx —
регистрирует del на post-commit. Ошибка хука не валит уже закоммиченный write.

Тест: порядок body→commit→hook, дренаж на внешней границе, глушение падения хука.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/telemetry/vitals анонимный, а route/attr не сверялись со словарём:
любой мог писать свободный текст в client_metrics (высокая кардинальность,
инъекция текста/PII/разметки).
- route: экспортировал полный словарь шаблонов из клиентского route-template.ts
  (KNOWN_ROUTE_TEMPLATES — канонический источник), сервер валидирует по зеркалу
  ALLOWED_ROUTE_TEMPLATES: нет в словаре → drop (null), событие остаётся.
- attr: это web-vitals attribution target (CSS-селектор), а не enum — ограничил
  консервативным charset CSS-селектора; всё вне набора → drop.
Клиентский self-consistency тест: templateRoute выдаёт ТОЛЬКО значения из
словаря (иначе легитимные метрики отбрасывались бы). Серверные тесты: raw-путь
и инъекция в route отброшены, PII/разметка в attr отброшены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Две дырки свипера временных заметок:
- Не было свипа на старте: заметки, протухшие во время простоя, ждали до часа
  до первого тика @Interval. Добавил onApplicationBootstrap (best-effort, не
  блокирует boot).
- Гонка без блокировки: re-check и removePage не были атомарны, «Сделать
  постоянной» могла проскочить между ними. Теперь re-check идёт в транзакции с
  FOR UPDATE SKIP LOCKED: сериализуется с toggleTemporary (тот же row-lock) и
  пропускает строки, захваченные другим воркером/инстансом (без двойной
  обработки). Удаление идёт ВНУТРИ этой транзакции.

removePage получил опциональный trx: чтобы удалять под блокировкой без deadlock
на вложенной независимой транзакции. Броадкаст PAGE_SOFT_DELETED при переданном
trx отложен на commit через registerAfterCommit (откат больше не рассылает
фантомное удаление); без trx поведение прежнее.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 GIN trigram-индекса (pages.title, pages.text_content) + users.name строились
plain CREATE INDEX внутри Kysely-транзакции миграции: SHARE-lock блокирует записи
на pages/users на минуты при автодеплое.

Kysely гоняет миграцию в транзакции, а CONCURRENTLY внутри транзакции нельзя.
Поэтому ensureConcurrentIndexes (concurrent-indexes.ts) пре-строит эти индексы
через CREATE INDEX CONCURRENTLY (raw, вне транзакции) ДО миграатора — на
существующей БД миграционный CREATE INDEX IF NOT EXISTS становится no-op и лок не
берётся. Best-effort: на свежей БД (нет pages/f_unaccent) пре-build молча
пропускается, а миграция строит индекс на пустой таблице. Худший случай = прежнее
поведение, лучший — без лока.

CONCURRENT_INDEXES — канонические определения; drift-guard тест сверяет их с
выражениями в миграциях. Тест раннера: CONCURRENTLY+IF NOT EXISTS, вне
транзакции, best-effort (падение одного не рвёт остальные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
При ChunkLoadError граница перезагружала страницу один раз, гейтируя
булевым флагом в sessionStorage, который никогда не сбрасывался. Из-за
этого ВТОРОЙ деплой за время жизни вкладки не давал авто-восстановления:
пользователь застревал на битом чанке без перезагрузки.

Заменяю one-shot флаг на счётчик по временному окну: не более одной
авто-перезагрузки за 5 минут. В sessionStorage храню метку времени
последней перезагрузки; на ChunkLoadError перезагружаемся только если
прошлая была раньше окна (или её не было), иначе проваливаемся в ручной
UI без перезагрузки. Это восстанавливает работу через несколько деплоев,
но не даёт бесконечного цикла при навсегда битом lazy-чанке (сброс флага
после успешного маунта отвергнут: оболочка монтируется, чанк 404 —
и цикл).

Решение об окне вынесено в чистый хелпер shouldAutoReload(now,
lastReloadAt, windowMs) и покрыто юнит-тестами: никогда-не-грузили →
можно; 6 минут назад → можно; 1 минуту назад → нельзя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
canUndo/canRedo в use-toolbar-state читали приватные внутренности y-undo
(undoManager.undoStack.length / redoStack.length). Апгрейд yjs / y-undo,
переименовавший или перестроивший эти поля, тихо сломал бы состояние кнопок
undo/redo (или упал бы на .length у undefined) без единой ошибки.

Оставляем дешёвое чтение длины стеков (сознательно не используем дорогой
editor.can().undo()/.redo(), который делает dry-run на каждый keystroke,
см. комментарий в файле), но теперь feature-detect: доверяем стекам только
если это реально массивы, иначе откатываемся на безопасный дефолт
(prosemirror-history undoDepth/redoDepth -> 0). Логика вынесена в чистую
функцию yHistoryAvailability.

Добавлен pin-test, фиксирующий текущую форму библиотеки: реальный
Y.UndoManager по-прежнему отдаёт undoStack/redoStack массивами. Апгрейд,
меняющий контракт, упадёт громко в тесте, а не тихо в UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Добавлены переводы для 151 ключа, присутствовавшего в en-US, но
отсутствовавшего в ru-RU (настройки ИИ, диктовка, MCP, HTML-вставки,
роли агента и др.), включая ранее непереведённые «Streaming dictation»
и «Save and test». Технические токены и бренды (MCP, URL, Docmost AI,
плейсхолдеры версий) намеренно оставлены как есть. Добавлены русские
формы множественного числа (_few/_many) для «result found». Файл
переупорядочен в соответствии с порядком ключей en-US.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ночной property-фаззер при находке контрпримера заводил/обновлял issue,
дедуплицируя по ПРЕФИКСУ ЗАГОЛОВКА. Из-за этого при уже открытом issue по
багу A другой баг B с тем же префиксом заголовка считался дубликатом и
молча терялся до закрытия первого issue — реальные вторые баги глотались.

Теперь дедуп идёт по стабильному короткому хэшу самого контрпримера:
- из вывода fast-check извлекается блок «Counterexample:» (минимальный
  падающий вход) до строки «Shrunk N time(s)»/«Got error»; сид, path и
  счётчик усадки в хэш НЕ входят, поэтому один и тот же баг с разными
  сидами даёт один хэш;
- sha256, первые 12 hex-символов, кладутся в заголовок и в
  машиночитаемый маркер тела `<!-- counterexample-hash: ... -->`;
- поиск открытого issue матчит этот хэш в заголовке или маркер в теле.

Итог: два РАЗНЫХ контрпримера дают два РАЗНЫХ issue, а повторная находка
ТОГО ЖЕ контрпримера по-прежнему схлопывается в существующий.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Аспект A — идентичность прогона реиндекса эмбеддингов.
У статуса/поллинга реиндекса не было идентичности КОНКРЕТНОГО прогона, из-за
чего класс багов «это тот же прогон или новый?» чинили дважды. Теперь каждый
прогон получает свой runId (crypto.randomUUID в start()), он хранится в Redis-
хэше рядом с total/done/startedAt и возвращается в ReindexProgress. Эндпоинт
статуса (getMasked -> MaskedAiSettings) отдаёт runId и reindexStartedAt. Клиент
кеит поллинг на (runId, startedAt): смена runId = НОВЫЙ прогон (сбрасываем
залатанное per-run состояние поллинга), тот же runId = тот же прогон. Всё
best-effort/косметика как и остальной код прогресса: пустой/отсутствующий runId
деградирует мягко и никогда не ломает реиндекс.

Аспект B — брендированные PageId/SlugId в MCP-клиенте + валидация формата в
серверных DTO (семейство инцидентов #435: двойная идентичность страницы —
внутренний id и slugId — гонялась как голая строка и молча путалась).
- packages/mcp/src/lib/page-id.ts: номинальные типы PageId/SlugId + валидирующие
  конструкторы asPageId/asSlugId и гварды isPageId/isSlugId (формат UUID и
  10-символьного slugId). PageId протянут через единственный узел канонизации и
  записи: resolvePageId() теперь возвращает PageId, а ключ per-page лока
  (withPageLock) и точки записи в collab (mutatePageContent/replacePageContent/
  updatePageContentRealtime) требуют бренд — сырой slugId/непроверенный id больше
  не проходит проверку типов (инвариант #260 «resolve-then-lock» теперь на уровне
  компилятора). Публичный вход методов остаётся строкой (это legitimно UUID ИЛИ
  slugId), брендируется каноническое значение.
- apps/server core/page/dto: PageIdDto.pageId получает валидацию формата
  (@Matches, UUID или 10-символьный slugId), так что кривая/подменённая
  идентичность отклоняется на границе, а не падает в repo голой строкой.

Тесты (все зелёные, с mutation-verify каждого):
- server: getMasked отдаёт runId/reindexStartedAt; стор пишет/читает runId и
  мягко деградирует его до ''; DTO отклоняет кривой pageId/slugId и принимает
  валидный.
- client: чистый хелпер reindexRunKey/isNewReindexRun (кеинг поллинга на runId).
- mcp: конструкторы/гварды PageId/SlugId (валидация формата).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит 4 закрыл утечку currentPageId на анонимном /availability, но
эквивалентная (и более широкая) дыра оставалась на POST /aliases/set (reassign):
при занятом алиасе и confirmReassign=false 409 отдавал currentPageId И
currentPageTitle ЦЕЛЕВОЙ страницы, а контроллер гейтил только validateCanEdit на
ИСХОДНОЙ странице. Любой участник с одной редактируемой+расшаренной страницей мог
перебирать имена алиасов и мапить их на (id, title) чужих страниц без права
просмотра — тот же класс перечисления, плюс ещё и заголовок.

Фикс: setAlias теперь гейтит раскрытие. currentPageId НЕ отдаётся никогда
(клиент им не пользуется, это перечислимая идентичность). currentPageTitle —
только если validateCanView(целевая, user) проходит; иначе голый «занят» (клиент
и так показывает generic confirm-модалку без заголовка — UX не ломается).
Гейт живёт в сервисе, где строится раскрытие (PageAccessService — @Global, без
цикла); контроллер прокидывает user. Поправлен неточный коммент checkAvailability.

Тесты: viewer → 409 с title, БЕЗ id; не-viewer → 409 без title и без id.
Mutation-verify: вернул утечку (id + безусловный title) → оба теста краснеют.
Контроллер-спек и int-spec обновлены под новую сигнатуру; tsc чист.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #516: фича была мёртвой-при-рождении ровно на целевом кейсе. Все три
CONCURRENT_INDEXES используют LOWER(f_unaccent(col)), но index-инлайнящаяся
1-арг форма f_unaccent (SELECT public.unaccent($1)) создаётся ВНУТРИ миграции
20260705, а ensureConcurrentIndexes зовётся ПЕРЕД мигратором. На существующем
тенанте живой f_unaccent — ещё старая 2-арг форма из 20250729, которая НЕ
инлайнится: CREATE INDEX CONCURRENTLY падает («function unaccent(unknown, text)
does not exist … during inlining»), best-effort глотает, и мигратор строит
индексы НЕ-concurrently под тем самым SHARE-локом (эмпирически подтверждено
ревьюером на живом pg).

Фикс: перед циклом идемпотентно переопределяем f_unaccent в 1-арг форму
(output-identical, в lockstep с 20260705), в том же best-effort try/swallow.

Плюс честный докстринг: убрал ложное «worst case = previous behaviour».
Прерванный CONCURRENTLY оставляет INVALID-индекс, который name-based IF NOT
EXISTS не чинит — новый режим отказа (старый in-tx build такого не оставлял).

Тест: переопределялка вызывается ПЕРВОЙ (order), затем индексы CONCURRENTLY вне
транзакции; провал переопределялки не рвёт цикл. Mutation-verify: убрать
pre-loop redef → order-тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #516: fakeDb.execute всегда пушил commit — моделировался только commit-путь,
несущий негатив (тело кидает → tx реджектит → дренаж после awaited-tx не бежит →
хук НЕ фаerr) не был заперт. Исходник корректен, это была дыра в тесте.

Добавлен тест: тело бросает → executeTx реджектит, commit не случился, хук не
побежал. Mutation-verify: перенос дренажа в finally → тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревью #516: isPageId/asPageId/isSlugId/asSlugId/SLUG_ID_RE + типы SlugId/PageRef
имели НОЛЬ вызовов в монорепе — только барные ре-экспорты в client.ts и кейсы
теста. Несущий смысл — только compile-time бренд PageId (минтится as PageId в
resolvePageId, единственном узле канонизации; asPageId туда намеренно не звался).
Докстринг заявлял «asPageId() guards the untrusted PUBLIC boundary» — но никто не
звал: спекулятивный вес.

Удалил мёртвые runtime-символы + типы SlugId/PageRef + ре-экспорты + их тест-файл.
Оставил тип PageId + касты. Поправил врущий коммент в resolvePageId (бренд —
чистый compile-time маркер, гарантия — что этот узел единственный производитель).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder force-pushed feat/495-tails from 4e5f6b6e0b to 45ff922dd4 2026-07-12 01:06:59 +03:00 Compare
Author
Collaborator

Ребейзнут на 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.ts 27 зелёных, обе стороны в тестах). CHANGELOG — обе секции [Unreleased]. Заодно ужесточён флейки move-page.dto тест (jittered-RNG цикл 40→200 итераций). Гейт после ребейза: сервер 227, клиент 94, mcp 832; server tsc 0 (кроме предсущ. drawio import.meta). #486 drain-маркер и patches/lockfile — как в develop.

Ребейзнут на 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.ts` 27 зелёных, обе стороны в тестах). CHANGELOG — обе секции [Unreleased]. Заодно ужесточён флейки move-page.dto тест (jittered-RNG цикл 40→200 итераций). Гейт после ребейза: сервер 227, клиент 94, mcp 832; server tsc 0 (кроме предсущ. drawio import.meta). #486 drain-маркер и patches/lockfile — как в develop.
Collaborator

🤖 Ре-ревью · #516 (хвосты стабилизации, #495) round 2 — голова 45ff922d. Вердикт: PASS

Все 4 DO закрыты; главную (мёртвую-при-рождении concurrent-фичу) эмпирически перепроверил на живом pg:

  1. [CRITICAL: f_unaccent dead-on-arrival]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 уходит.
  2. [after-commit rollback test] — добавлен «does NOT run afterCommit hooks when the transaction body throws (rollback)». Мутация-проверка: заставил дренаж бежать и на rollback → тест краснеет → не вакуумен. (Дренаж структурно после awaited-tx, rollback его пропускает — корректно.)
  3. [docstring] — «Worst case = previous behaviour» убрано (0); INVALID-индекс-ограничение теперь честно задокументировано (+ заметка про будущий indisvalid=false-DROP).
  4. [dead page-id runtime]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.

## 🤖 Ре-ревью · #516 (хвосты стабилизации, #495) round 2 — голова `45ff922d`. Вердикт: **PASS** ✅ Все 4 DO закрыты; главную (мёртвую-при-рождении concurrent-фичу) **эмпирически перепроверил на живом pg**: 1. **[CRITICAL: f_unaccent dead-on-arrival]** — `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 уходит. 2. **[after-commit rollback test]** — добавлен «does NOT run afterCommit hooks when the transaction body throws (rollback)». **Мутация-проверка:** заставил дренаж бежать и на rollback → тест краснеет → не вакуумен. (Дренаж структурно после awaited-tx, rollback его пропускает — корректно.) 3. **[docstring]** — «Worst case = previous behaviour» убрано (0); INVALID-индекс-ограничение теперь честно задокументировано (+ заметка про будущий `indisvalid=false`-DROP). 4. **[dead page-id runtime]** — `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.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 02:52:42 +03:00
Collaborator

Независимый спот-чек: мержить можно. Карта покрытия против ишью #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'ом.

Независимый спот-чек: **мержить можно.** Карта покрытия против ишью #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'ом.
vvzvlad merged commit 801add63e8 into develop 2026-07-12 04:03:56 +03:00
Sign in to join this conversation.