fix(share): custom address edit renames in place instead of duplicating (#226) #227
Reference in New Issue
Block a user
Delete Branch "fix/share-alias-rename"
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?
Закрывает #226 (регрессия #205).
Причина
Редактирование кастомного адреса (
te→ted) делало INSERT новой строки-алиаса вместо переименования (уникальность была только по(workspace_id, alias), не поpage_id), аfindByPageId().executeTakeFirst()безORDER BYотдавал произвольную (старую) строку → ссылка не обновлялась + копились осиротевшие живые/l/<старое>.Фикс
setAlias()— инвариант «ровно один адрес на страницу»: свободное имя + у страницы уже есть алиас → переименование на месте; нет → INSERT; то же имя → no-op; занято другой страницей → прежняя 409ALIAS_REASSIGN_REQUIRED; после успеха →deleteOthersForPage(самолечение). В транзакции.updateAlias,deleteOthersForPage;findByPageIdдетерминирован (ORDER BY createdAt DESC, id DESC).20260627T120000-share-aliases-one-per-page— дедуп существующих строк (оставляет новейшую) + частичный уникальный индекс(workspace_id, page_id) WHERE page_id IS NOT NULL(dangling-алиасы с NULL сосуществуют). Reversible.Проверка
56 unit + 6 integration (живой pg, миграция применяется чисто) зелёные; tsc +
nest buildок.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
🤖 Generated with Claude Code
Code review — multi-aspect (security · stability · conventions · docs · regressions · test-coverage · simplification · architecture)
Вердикт: Request changes. Ревью на реальном коде ветки против
develop(merge-basee9409e24), 6 файлов, +584/−53. Сам фикс корректен для основных сценариев (renamete→tedна месте, self-heal, детерминированныйfindByPageId, миграция с дедупом и частичным уникальным индексом). Но есть один блокирующий баг корректности: подтверждённый перенос адреса (confirmReassign) на страницу, у которой уже есть собственный алиас, теперь падает из-за порядка операций относительно нового уникального индекса. Критичных проблем (outage / потеря данных / эксплойт) нет — поэтому Request changes, а не Block merge. Security и Conventions — чисто (LGTM).⛔ Must fix before merge
[stability/regressions] Переставьте удаление перед
updatePageIdв ветке подтверждённого reassign —share-alias.service.ts:96-108В ветке «имя занято другой страницей +
confirmReassign» код сначала делаетupdatePageId(byName.id, pageId)и только потомdeleteOthersForPage. Новая миграция20260627T120000добавляет обычный (non-deferrable) частичный уникальный индекс(workspace_id, page_id) WHERE page_id IS NOT NULL, который Postgres проверяет немедленно по завершении каждого statement (уникальный индекс нельзя сделатьDEFERRABLE).Реалистичный сценарий: у страницы A адрес
shared, у страницы B уже есть свой адресbee; пользователь подтверждает переносsharedна B. На шагеupdatePageIdпоявляются две строки сpage_id = B→ немедленный23505до того, как отработаетdeleteOthersForPage. Транзакция откатывается, а catch на строках 152-153 переводит это в вводящую в заблуждение ошибкуConflictException('Alias already taken')— хотя имя как раз было свободно занять. До этого PR ограничения(workspace_id, page_id)не было и такой swap работал → это регрессия ранее работавшего сценария.Fix: в этой ветке сначала вызвать
deleteOthersForPage(pageId, byName.id, workspaceId, trx)(удалит существующий алиас целевой страницы;byName.idпока указывает на старую страницу, поэтому исключение поkeepIdбезвредно), затемupdatePageId(byName.id, pageId, workspaceId, trx)— после этогоbyName.idостаётся единственной строкой дляpageId, завершающийdeleteOthersForPageуже не нужен. Заодно это делает истинным JSDoc на строках 50-53 («…the page never transiently has zero or duplicate rows»), который сейчас противоречит фактическому порядку в этой ветке (та же неточность продублирована в теле коммита). Добавьте интеграционный тест на этот случай (см. «Test coverage»).[warning][stability] Различайте, какой именно индекс дал
23505—share-alias.service.ts:144-157Catch трактует любой
23505как «имя уже занято». После добавления второго уникального индекса(workspace_id, page_id)это уже не всегда так (гонка двухsetAliasпо одной странице, да и баг выше). Fix: анализироватьerr.constraint/err.detail— маппитьshare_aliases_workspace_id_alias_unique→ «Alias already taken», аshare_aliases_workspace_id_page_id_unique→ отдельный код/сообщение; как минимум логироватьerr.constraint.[suggestion][regressions] Опишите в release notes, что дедуп-миграция гасит старые
/l/<old>ссылки —20260627T120000-share-aliases-one-per-page.ts:19-26DELETEвup()необратимо удаляет все строки страницы кроме новейшей. Старые слаги резолвятся публичным редиректом черезfindByAliasAndWorkspace(неfindByPageId), так что ранее живые/l/<старое-имя>после апгрейда начнут отдавать SPA-404. Это и есть суть фикса (гасятся ровно дубликаты от старого бага), но удаление тихое и необратимое (down()восстанавливает индекс, но не строки). Изменений в коде не требуется — только пометка в описании/чейнджлоге.Test coverage
Покрытие в целом хорошее (56 unit + 6 integration), но есть пробелы:
share-alias-one-per-page.int-spec.ts:181-214. Текущий «cross-page collision» переносит имя на страницу B без собственного алиаса, поэтому баг выше не ловится. Добавьте: A←shared, B←bee, затемsetAlias({pageId: B, alias: 'shared', confirmReassign: true})→ у B ровно одна строкаshared, у A — ноль.DELETEпроверяется только на одной странице/одном workspace —share-alias-one-per-page.int-spec.ts:94-131. Скоупинг self-join по(workspace_id, page_id)(что DELETE не залезает в чужие страницы/воркспейсы) не проверен; к тому же SQL дублируется инлайном вместо вызоваup()миграции. Засейте дубликаты для ≥2 страниц (и второго workspace).BadRequestException('Failed to set alias')(share-alias.service.ts:155-156) и откат при ошибке в середине транзакции не покрыты.Architecture & design (forward-looking, non-blocking)
Инвариант «один алиас на страницу» проверяется БД-индексом в середине транзакции → сервис вынужден аккуратно упорядочивать statements. Это и есть корень must-fix-бага.
оставить частичный индекс, починить порядок в reassign-ветке* (effort: s). Минимально, сохраняет dangling-поведение. Это и есть рекомендованный must-fix.
Self-heal
deleteOthersForPageвызывается в каждой из трёх ветвей записи — признак отсутствующего примитива «ровно один алиас на страницу».свести
deleteOthersForPageк одному вызову в концеsetAlias* (effort: s): безопасно только вместе с фиксом порядка в reassign-ветке.cf6b78bca1toe682bbccd1Code review — PR #227 «fix(share): редактирование кастомного адреса переименовывает строку, а не дублирует» (#226)
Вердикт: Approve with comments (одобрить с замечаниями).
Изменение корректное и хорошо протестированное (56 unit + 6 integration на живом Postgres). Блокирующих и критичных проблем нет — все 8 аспектов (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) либо вернули LGTM, либо только неблокирующие suggestion'ы. Ядро фикса — порядок DELETE→UPDATE при свопе относительно non-deferrable частичного индекса
(workspace_id, page_id), детерминированныйfindByPageId, самолечениеdeleteOthersForPageи миграция-дедуп — проверены трассировкой ветвей и подтверждены.Объём ревью: диф относительно базы
develop(b6630deb..e682bbcc), 7 содержательных файлов.Must fix before merge
[suggestion][stability] Защитить swap/rename от
undefined-строки при гонке с удалением внутри транзакции —apps/server/src/core/share/share-alias.service.ts:126-131(своп) и:145(rename).ShareAliasRepo.updatePageId/updateAliasиспользуют.returning(...).executeTakeFirst(), но типизированы какPromise<ShareAlias>. Если параллельныйremoveAliasудалит строкуbyName.idмежду чтением и UPDATE (READ COMMITTED, разные стейтменты), UPDATE не заматчит строк и вернётundefined. В swap-ветке этоundefinedуходит прямо в тело ответа 200 — «успех» без алиаса, нарушение контракта; в rename-веткеundefinedразыменовывается (row.id) и падает в общий 400 «Failed to set alias». Окно узкое (нужно удаление именно этой строки в момент свопа), поэтому не блокер.Fix: трактовать пустой результат UPDATE как гонку — при
undefinedбросатьConflictException(код вродеALIAS_PAGE_RACE/ «retry»), либо перевести эти методы репозитория наexecuteTakeFirstOrThrow().[suggestion][simplification] Убрать избыточный вторичный
logger.warn(и мёртвую константуUNIQUE_ALIAS_INDEX) — это же закрывает пробел в покрытии —apps/server/src/core/share/share-alias.service.ts:192-199.Нарушенный constraint уже безусловно логируется строкой выше (
:179-181). Вторичныйwarnдля «неожиданного» индекса не меняет поток управления — обе ветки бросают один и тот же'Alias already taken', аUNIQUE_ALIAS_INDEXиспользуется только здесь. Это объединяет находку test-coverage: ветка194-198— единственная новая вsetAlias, не покрытая тестом.Fix (одно из двух): (а) удалить блок
194-198и константуUNIQUE_ALIAS_INDEX— тогда непокрытая ветка исчезает; или (б) если оставляете её как ops-сигнал о неизвестном индексе — добавить unit-тест с{ code: '23505', constraint_name: 'some_unexpected_index' }, проверяющий'Alias already taken'.Test coverage
Покрытие новой логики обширное и осмысленное (ассерты, а не «тривиально зелёные»): rename-in-place (te→ted, дубля нет, старый линк мёртв), same-name no-op, insert, подтверждённый своп с порядком DELETE→UPDATE и удалением прежнего алиаса целевой страницы,
ALIAS_REASSIGN_REQUIRED(+currentPageTitle),ALIAS_PAGE_RACE, «Alias already taken»,findByPageIdnewest-wins на legacy-дублях,deleteOthersForPage, миграция-дедуп + частичный уникальный индекс (отвергает второй(workspace_id, page_id), допускает несколькоNULL). Эти тесты падали бы на до-фиксовом коде. Единственный незакрытый путь — логирующая ветка194-198(см. выше; мотивируется находкой simplification).Architecture & design (forward-looking, non-blocking)
2. Маппинг Postgres 23505 → ошибка приложения теперь продублирован в двух сервисах (
share-alias.service.tsиai-chat/roles/ai-agent-roles.service.ts; плюс «голая» проверка вfavorite.repo.ts).Дублируется именно хрупкая, привязанная к драйверу часть: SQLSTATE
'23505'и чтение имени индекса какerr.constraint_name ?? err.constraint(квиркkysely-postgres-js/postgres@3.x). Риск дрейфа: при апгрейде драйвера правку придётся вносить в N мест, ошибка — тихий мисмаппинг.вынести два хелпера_
isUniqueViolation(err)/violatedConstraint(err)вapps/server/src/database/utils.ts(effort: small). Плюсы: централизует драйвер-специфичную часть в одном месте (там же уже живутjsonbBind/parseJsonbValueпо тому же принципу); семантика «какое сообщение» остаётся в каждом сервисе. Минусы: затрагивает второй сервис, расширяя радиус PR.The share modal flagged a custom address already owned by another page with a red "This address is already in use" error driven by the availability probe. That reads as terminal even though Save actually triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens the "Move custom address?" confirm modal that retargets the address to the current page — so the reassign path was hidden behind what looked like a hard stop. Replace the red error with an informational description hint ("This address is in use. Saving will move it to this page.") and keep Save enabled, so the existing confirm-reassign flow is discoverable. Renaming to a FREE name was already correct (the probe returns available -> no error -> server renames the single row in place); this only changes the taken-name presentation. Verified end-to-end in a real browser against a live stand on this branch: - A (free rename `test`->`test2`): 200, same alias row renamed in place, link becomes `/l/test2`, no error, exactly one DB row for the page. - B (`test2` owned by another page): hint shown (no dead-end error), Save -> 409 ALIAS_REASSIGN_REQUIRED -> "Move custom address?" modal -> confirm -> 200, the single row retargets, one row each. - C (same-name re-save): Save disabled (no-op); first-time set inserts. Add a client component test covering both branches (taken name -> hint not error + Save enabled; 409 -> reassign modal -> confirm sends confirmReassign). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ghost referenced this pull request2026-06-28 04:23:39 +03:00