fix(#260): open MCP collab docs by canonical UUID (slugId doc-name split) #265
Reference in New Issue
Block a user
Delete Branch "fix/260-collab-docname-slugid"
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
Чинит реальную причину тихой потери правок MCP (issue #260, переоткрыт по твоим логам). closes #260
Корень — рассинхрон ИМЕНИ коллаб-документа (slugId vs UUID). Веб-редактор всегда открывает док по UUID (
page.${page.id}), а MCP открывал по агентскому id — обычно slugId →page.<slugId>. Для одной строкиpagesэто ДВА независимых Yjs-документа; оба пишут в ту же строку (findById/updatePageрезолвят и id, и slug) → debounced-стор вкладки человека затирает правку агента (last-store-wins): после F5 пусто, в живой вкладке не видно. Slug-док ещё и валил серверный transclusion-sync + реиндекс эмбеддинга в Postgres 22P02 (твои логи).Фикс
resolvePageId(pageId)→ канонический UUID (UUID short-circuit без сети; slugId резолвится раз черезgetPageRawи кэшируется в обе стороны). ВСЕ пути коллаб-записи (mutatePageContent/updatePageContentRealtime/replacePageContentи швы) теперь открывают док по UUID — MCP и редактор делят ОДИН Yjs-док. Page-lock вreplaceImageтоже завязан на UUID, чтобы сериализоваться с остальными (теперь UUID-ключёванными) записями.onStoreDocumentпередаёт резолвнутыйpage.id(а не сырой id из имени дока) вsyncTransclusion, embedding-очередь, mention-нотификацию,addContributorsи in-tx чтение истории. Content-store и empty-guard не тронуты.Что это НЕ
Не #248 (empty-guard — другой класс) и не мой прежний redis split-brain (реальный, но ДРУГОЙ баг). Этот — single-worker, ровно твоя среда.
How verified
На стенде:
resolve-page-id-collab-doc-name.test.mjsподнимает реальный Hocuspocus и ловитdocumentName: slugId-вход →page.<uuid>и НИКОГДАpage.<slugId>(для editPageText/tableInsertRow/generic-mutate); UUID-вход → 0 запросов/pages/info; повторный slugId → ровно 1 (кэш). Падал бы на до-фиксовом коде.tsc --noEmit— 0.persistence-store.spec.ts(+1 кейс:page.<slugId>-док → side-effects получаютpage.idUUID) — 14/14;tsc— 0.replaceImage(lock остался на slugId) — исправлено (lock на UUID), после чего APPROVE.Опционально могу поднять фикс-ветку на стенде и показать живьём (две сессии uuid+slug на одну страницу сходятся в один док).
Checklist
Real root cause of the silent MCP edit loss: the web editor always opens the collaboration document by the page UUID (`page.${page.id}`), but the MCP opened it by the agent-supplied id — usually a slugId — so `page.${pageId}` became `page.<slugId>`. For one DB page that is TWO independent Yjs documents; both persist to the same `pages` row (findById/updatePage resolve id or slugId), so the human tab's debounced store overwrites the agent edit (last-store-wins) — gone after reload, never shown live. The slugId doc also made the server's transclusion sync + embedding reindex throw Postgres 22P02. Fix: - MCP (primary): resolvePageId(pageId) returns the canonical UUID — a UUID short-circuits with no network call, a slugId resolves once via getPageRaw and is cached both ways. Every collab-write path (mutatePageContent / updatePageContentRealtime / replacePageContent and the mutate/replace/ unlocked seams) now opens by the resolved UUID, so the MCP and the editor share ONE Yjs doc. replaceImage's whole-operation page lock also keys on the UUID so it serializes against the other (now-UUID-keyed) writes. - Server (defense + kills the 22P02 noise): onStoreDocument passes the resolved page.id — not the raw doc-name id — to syncTransclusion, the embedding queue, the mention-notification job, addContributors, and the in-tx history read. Content store and the empty-guard are untouched. Tests: a new MCP test stands up a real Hocuspocus server and asserts a slugId input opens `page.<uuid>` (never `page.<slugId>`), with UUID short-circuit and single-resolve caching; the server spec asserts the side-effects receive the UUID for a `page.<slugId>` doc. closes #260 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ревью
3b80285d5— раунд 1, Full tier (9 аспектов вкл. COHERENCE). Закрывает #260 — класс тихой потери данных: MCP открывал collab-документ по slugId (page.<slugId>), а веб-редактор всегда по UUID (page.<uuid>) → на одну строку БД два независимых Yjs-документа, debounce-сторы затирали друг друга (last-store-wins), правка агента молча терялась; плюс 22P02 на uuid-типизированных колонках сервера.Вердикт: CHANGES (два пункта — один реальный пробел покрытия на самой рискованной изменённой ветке + одна доказуемо-мёртвая строка). Корневой фикс корректен и проверен по коду многими аспектами; цель #260 достигнута end-to-end. Отвечай по id.
Что проверено и ЧИСТО:
page.<uuid>на ВСЕХ путях — 17 collab-open сайтов ↔ 17resolvePageId(сверено построчно, первый аргумент каждогоmutatePageContent/updatePageContentRealtime/replacePageContent/replacePage/mutatePage/mutateLiveContentUnlocked— этоpageUuid/targetUuid). Имя то же, что у веб-редактора (page.${page.id}) → Hocuspocus дедуплицирует в ОДИН общий документ. Ключи блокировки сходятся на UUID:withPageLockи self-lockmutatePageContentключатся на переданном id (=резолвнутый uuid),replaceImageтожеwithPageLock(pageUuid)— рассинхрона мьютекс-ключа (а с ним TOCTOU/orphan-attachment окна) нет.onStoreDocumentпрокидывает резолвнутыйpage.idво все uuid-типизированные стоки —findPageLastHistory,syncTransclusion/references,addContributors, mention-job, embeddingPAGE_CONTENT_UPDATED→ 22P02 закрыт. Пара contributors↔PAGE_HISTORY теперь согласована (addContributors(page.id)↔ job{pageId: page.id}↔popContributors(page.id)); до фикса slugId-кейс клал contributors по slugId, а попал по uuid → молча терялись. Оставшийся сыройpageIdвupdatePageбезопасен (updatePagesветвится поisValidUUIDи пишет в колонку slugId — поэтому #260 был тихой потерей, а не жёсткой ошибкой).resolvePageIdкорректен (stability): slugId (10-символьный nanoid без дефисов) НИКОГДА не матчит 36-символьныйUUID_RE→ нет ложного short-circuit; реальные v4/v7 (version-ниббл 1–8, variant 8/9/a/b) матчат → нет ложного round-trip;getPageRaw().id— каноничный uuid (серверfindByIdрезолвит slugId поisValidUUID-ветке). UUID short-circuit — без сети.onAuthenticateнезависимо делаетgetPageId→findById(принимает uuid ИЛИ slugId → та же строка) + проверки роли в спейсе и прав на страницу для пользователя из JWT — резолв в UUID меняет только идентичность документа, не проверку. КэшpageIdCache— per-client/per-chat-request, межтенантной утечки нет;UUID_REякорный фиксированной длины — ReDoS нет.resolve-page-id-collab-doc-name.test.mjsподнимает РЕАЛЬНЫЙ Hocuspocus, ловит фактическийdocumentNameчерезonLoadDocument, гоняет РЕАЛЬНЫЙDocmostClient(build/client.js совпал с src), ассертитpage.<uuid>присутствует Иpage.<slugId>НИКОГДА (негативный ассерт), покрывает оба входа — slugId (резолв→uuid) и uuid (short-circuit, без/pages/info) — упал бы при откате фикса. Серверныйpersistence-store.spec.tsставитdocumentName: 'page.slug-1'(именно slugId, смок-ган), мокаетfindById→страница с другим PAGE_ID и ассертит, что во ВСЕ стоки попадает PAGE_ID, не slugId — прямо доказывает, что 22P02-путь закрыт. Три мелкие правки тестов (ambiguous-node-id/insert-footnote-wrapper/write-order) — легитимные адаптации под резолв, исходные ассерты сохранены.build/client.js— практика проекта (25 трекнутых build-файлов,pretest: tsc), паритет с src подтверждён; локальныйisUuidоправдан (MCP — отдельный пакет без зависимостиuuid); резолв клиентом — верный слой (веб-редактор уже открывает поpage.${page.id}, MCP просто нарушал контракт; серверная канонизация имени — несоразмерный future-тикет, не замена). Инвариант трёх вендоренных схем (editor-ext/mcp/git-sync) не затронут — правок марок/атрибутов нет. Все 8 содержательных утверждений в новых комментариях сверены с кодом — верны.Что сделать
F1 [test coverage] Покрыть резолв в
replaceImage— open И ключ блокировки —packages/mcp/src/client.ts:3099-3140Это единственный изменённый путь, где на резолвнутый UUID завязаны ОБА: collab-open (
mutateLiveContentUnlocked→page.${pageUuid}, client.ts:474) И ключ мьютексаwithPageLock(pageUuid)(client.ts:3121). Новый mock-тестreplaceImageне вызывает ни разу;page-lock.test.mjsгоняетwithPageLockтолько с литеральными ключами. Значит задокументированная регрессия — если кто-то заключит блокировку по сырому slugId, рассинхронив её с uuid-ключомmutatePageContentи снова открыв TOCTOU/orphan-attachment окно, которое эта блокировка и закрывает — НЕ будет поймана. Это самая рискованная и самая отличная от остальных ветка изменения (отдельный helper + отдельный инвариант «ключ блокировки == ключ имени документа»), и у неё ноль покрытия. (Severity suggestion, но это фикс потери данных — покрытие его собственного рискового инварианта и есть планка; ветка достижима, окружение поднимает реальный Hocuspocus.)Fix: добавить в
resolve-page-id-collab-doc-name.test.mjsкейс сclient.replaceImage(SLUG, ...), ассертить (a) пойманныйdocumentName===page.<uuid>и НИКОГДАpage.<slugId>, и (b) ключ блокировки == резолвнутый UUID (заспаить/застабитьwithPageLockЛИБО проверить сериализацию против конкурентногоmutatePageContentна той же странице — рассинхрон slugId-vs-uuid ключа провалит тест).F2 [simplification] Удалить мёртвую запись
pageIdCache.set(uuid, uuid)—packages/mcp/src/client.ts:798resolvePageIdвозвращается наif (isUuid(pageId)) return pageId(client.ts:787) ДО чтения кэша (client.ts:788), поэтомуpageIdCache.getвызывается ТОЛЬКО с ключами-slugId — uuid-ключ никогда не читается, иset(uuid, uuid)пишет запись, которую никто не достаёт (мёртвый код; подтвердили два аспекта). Комментарий поля и docstring при этом утверждают «Both slugId->uuid and uuid->uuid are cached» — описывают поведение, которого нет. Удали строку 798 и подрежь оба утверждения до «slugId->uuid». Чистая уборка, без изменения поведения.⛔ DROP — кодеру НЕ делать (калибровка)
[below-threshold]low/med[stability]pageIdCacheрастёт без эвикции —client.ts:176: ids неизменны (нет staleness), записи — крошечные строки, скоуп per-user; практического эффекта нет. LRU — только для очень долгоживущего standalone-процесса.[below-threshold]low/med[stability]resolvePageIdне дедуплицирует in-flight —client.ts:786: две конкурентные первые правки одного slugId дважды дёрнут/pages/info, но обе резолвят тот же uuid → конвергенция ключей и фикс держатся; лишний запрос безвреден.[below-threshold]low/low[regressions] битый/несуществующий slugId теперь кидает рано (сырой axios-error) вместо мягкого no-match —client.ts:786: fail-fast скорее корректнее; дружелюбная обёртка 404 опциональна.[below-threshold]low/med[documentation/stability] комментарийUUID_REговорит «matches uuid.validate», ноvalidateпринимает ещё NIL/MAX —client.ts:135: несущественно (реальные ids не NIL/MAX), несущая гарантия (slugId не спутать с uuid) держится.Объективные проверки: vitest/jest сам прогнать не могу (в окружении ревью нет
@hocuspocus/server); базис — существующие и новый тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального исходника (test-coverage сверил тела с прод-кодом, documentation+coherence подтвердили паритет build↔src и истинность всех утверждений-комментариев), кодер отчитался о прогоне. Корневой фикс готов; нужны F1 (покрытие рискового инвариантаreplaceImage) и F2 (мёртвая строка).Маркер
reviewed_headобновлён на3b80285d5. После правки верниreview/needs.F1: fixed — добавил кейс на
replaceImageвresolve-page-id-collab-doc-name.test.mjs, закрывающий ОБА инварианта этого пути (open по UUID + ключ блокировки по UUID). Подход — детерминированная сериализация (неmock.module: на Node 20 он за--experimental-test-module-mocks, плюс статический импортDocmostClientрезолвит биндингwithPageLockдо тел тестов — перепривязать нельзя). Тест гейтит/files/upload, так чтоreplaceImageпаркуется в середине аплоада, ДЕРЖА page-lock; затем: (a)state.docNames === [page.<uuid>], никогдаpage.<slug>; (b) импортируюwithPageLockиз ТОГО ЖЕ инстансаbuild/lib/page-lock.js(ESM кэширует по URL → общийchains-мьютекс) и ставлю проб на ключ UUID — он ОБЯЗАН остаться заблокированным, покаreplaceImageдержит UUID-лок (+ страховочный проб на свободный ключ, который проходит — не-вакуумность). Проверил: при откате ключа на slugId тест падает на (b). MCP-сьют 430/430, tsc 0.F2: fixed — убрал мёртвую
pageIdCache.set(uuid, uuid):resolvePageIdвозвращается наisUuid()-short-circuit ДО чтения кэша, поэтому uuid-ключ никогда не читается; хранятся/читаются только slugId→uuid. Комментарий поля поправил под реальное поведение.Возвращаю
review/needs.Ревью
e04afee629— раунд 2 (правки F1/F2), спотлайт на двух правках + проверка отсутствия регрессий.Вердикт: PASS. Оба пункта закрыты и сверены по коду. Готово к мержу.
replaceImage opens by the resolved UUID AND keys its page lock by that UUIDпинит ОБА инварианта рискованной ветки:/files/upload, паркуетreplaceImageВНУТРИ его page-lock (после скана, в аплоаде) и ассертитstate.docNames === ['page.<UUID>']— скан-открытие по резолвнутому UUID, не по slugId; в конце['page.<UUID>','page.<UUID>']иpage.<SLUG>не появляется никогда.withPageLockизbuild/lib/page-lock.js, что иbuild/client.js(ESM кэширует по URL → общая мьютекс-картаchains), и покаreplaceImageдержит блокировку — пробаwithPageLock(UUID)остаётся в очереди (probeRan===false), а проба на свободном ключе проходит за тот же flush (freeRan===true). Откат любого инварианта (open по slugId ИЛИ lock по slugId) валит тест; sanity-проба исключает «flush никого не запустил». Сверено:replaceImageреально держитwithPageLock(pageUuid)черезuploadImage(блокировка охватывает scan→upload→write),page-lock.jsэкспортитwithPageLock— механика пробы корректна.pageIdCache.set(uuid, uuid)удалена (в src и в build/client.js), комментарий поля переписан корректно: «A UUID input short-circuits before this cache … only slugId->uuid entries are stored/read here». Поведение не изменилось.Остальная часть PR не менялась с прошлого раунда (корневой фикс #260 был верифицирован чистым 9 аспектами) — регрессий новый коммит не вносит (только тест + удаление мёртвой строки + комментарий). build↔src паритет сохранён.
Объективные проверки: vitest сам прогнать не могу (в окружении ревью нет
@hocuspocus/server); базис PASS — новый тест НЕЗАВИСИМО верифицирован не-вакуозным против реального исходника (охват блокировкиreplaceImage, экспортwithPageLock, паритет build подтверждены чтением), кодер отчитался о прогоне; F2 — чистое удаление + комментарий, тест не нужен.Маркер
reviewed_headобновлён наe04afee629.