feat(#251): intentional-clear signal editor→store (persist deliberate clear, keep #248 guard) #253

Open
agent_coder wants to merge 2 commits from feat/251-intentional-clear into develop
Collaborator

Summary

Проводит сигнал намеренной очистки (intentionalClear) от редактора до store, чтобы намеренная очистка страницы (select-all + Delete) персистилась, а store-side empty-guard из #248 по-прежнему блокировал случайное затирание пустым. Closes #251.

Определение намеренной очистки: ЛОКАЛЬНАЯ user-транзакция, сводящая непустой doc к пустому single-paragraph (docChanged, не remote y-sync через isChangeOrigin, было непусто → стало isEmptyParagraphDoc). Это путь select-all+Delete/Backspace и команд типа clearContent. Remote/merge-пустота не квалифицируется.

Транспорт (вариант b — hocuspocus stateless): клиент на очищающей транзакции шлёт {type:'intentional-clear'} через provider.sendStateless; сервер PersistenceExtension.onStateless ставит короткоживущий single-use pending-флаг по documentName (TTL 60s > maxDebounce 45s). onStoreDocument на ветке empty-over-non-empty пускает запись только если consumeIntentionalClear вернул живой флаг, иначе блокирует (#248). Не спуфится: документ берётся из соединения (не из payload), read-only не армит, флаг читается ТОЛЬКО на guard-ветке, single-use + TTL, любая непустая запись сбрасывает флаг. Redis multi-master: redis-sync проводит stateless через стандартный пайплайн на узле-владельце документа (set/consume co-located).

How verified

  • server jest src/collaboration: 68/68 (вкл. все #248 guard-тесты + новые #251).
  • client vitest src/features/editor: 140 pass (+1 pre-existing expected-fail, unrelated).
  • server tsc + client tsc: чисто.
  • Регресс-тест реальным путём (НЕ hand-poke): server-тест армит флаг через настоящий onStateless({connection,documentName,payload:JSON.stringify({type:'intentional-clear'})}) (точный клиентский wire-месседж) → debounced onStoreDocument с пустым Y.Doc → пустой контент записан; + single-use (2-я пустая блокируется), read-only reject, «непустая запись сбрасывает флаг», и неизменные #248 guard-тесты. client-тест гоняет реальный editor.chain().selectAll().deleteSelection().run() → проверяет sendStateless({type:'intentional-clear'}); негативы (печать/не-очистка/уже-пусто) ничего не шлют.

Review checklist

  • AC #251 выполнены (эмит сигнала, per-edit доставка, персист намеренной очистки, guard #248 держится, регресс реальным путём)
  • guard #248 не ослаблен для обычных записей

⚠️ Зависимость от #248

Store-side empty-guard (#248) ещё НЕ в develop (живёт в PR #248). Эта ветка ВКЛючает блок guard как фундамент (идентичен #248, помечен комментарием). При мерже #248 в develop — ребейз чистый, дублирующий блок схлопывается. Ревьюить #251 имеет смысл после/вместе с #248.

🤖 Generated with Claude Code

## Summary Проводит сигнал намеренной очистки (`intentionalClear`) от редактора до store, чтобы намеренная очистка страницы (select-all + Delete) персистилась, а store-side empty-guard из #248 по-прежнему блокировал случайное затирание пустым. Closes #251. **Определение намеренной очистки:** ЛОКАЛЬНАЯ user-транзакция, сводящая непустой doc к пустому single-paragraph (docChanged, не remote y-sync через isChangeOrigin, было непусто → стало isEmptyParagraphDoc). Это путь select-all+Delete/Backspace и команд типа clearContent. Remote/merge-пустота не квалифицируется. **Транспорт (вариант b — hocuspocus stateless):** клиент на очищающей транзакции шлёт `{type:'intentional-clear'}` через `provider.sendStateless`; сервер `PersistenceExtension.onStateless` ставит короткоживущий single-use pending-флаг по documentName (TTL 60s > maxDebounce 45s). `onStoreDocument` на ветке empty-over-non-empty пускает запись только если `consumeIntentionalClear` вернул живой флаг, иначе блокирует (#248). Не спуфится: документ берётся из соединения (не из payload), read-only не армит, флаг читается ТОЛЬКО на guard-ветке, single-use + TTL, любая непустая запись сбрасывает флаг. Redis multi-master: redis-sync проводит stateless через стандартный пайплайн на узле-владельце документа (set/consume co-located). ## How verified - server `jest src/collaboration`: **68/68** (вкл. все #248 guard-тесты + новые #251). - client `vitest src/features/editor`: **140 pass** (+1 pre-existing expected-fail, unrelated). - server tsc + client tsc: чисто. - Регресс-тест реальным путём (НЕ hand-poke): server-тест армит флаг через настоящий `onStateless({connection,documentName,payload:JSON.stringify({type:'intentional-clear'})})` (точный клиентский wire-месседж) → debounced `onStoreDocument` с пустым Y.Doc → пустой контент записан; + single-use (2-я пустая блокируется), read-only reject, «непустая запись сбрасывает флаг», и неизменные #248 guard-тесты. client-тест гоняет реальный `editor.chain().selectAll().deleteSelection().run()` → проверяет `sendStateless({type:'intentional-clear'})`; негативы (печать/не-очистка/уже-пусто) ничего не шлют. ## Review checklist - [ ] AC #251 выполнены (эмит сигнала, per-edit доставка, персист намеренной очистки, guard #248 держится, регресс реальным путём) - [ ] guard #248 не ослаблен для обычных записей ## ⚠️ Зависимость от #248 Store-side empty-guard (#248) ещё НЕ в develop (живёт в PR #248). Эта ветка ВКЛючает блок guard как фундамент (идентичен #248, помечен комментарием). При мерже #248 в develop — ребейз чистый, дублирующий блок схлопывается. Ревьюить #251 имеет смысл после/вместе с #248. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: cce539e8e297e1e15dea608895fe13bbd51ab18c baseline_head: cce539e8e297e1e15dea608895fe13bbd51ab18c verdict: changes-requested round: 1 max_rounds: 6 open_findings: [F1, F2] reopened: {} -->
agent_coder added 1 commit 2026-06-29 04:08:16 +03:00
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to
overwrite non-empty persisted content with an empty document, because a
momentarily-empty live Y.Doc is indistinguishable from a real clear at the
store layer. That correctly blocks glitches/bad-merges, but also blocks a user
who genuinely wants to empty a page. This re-introduces a WORKING, narrow,
non-spoofable exception (the dead context.intentionalClear hatch #248 removed
never had a real channel).

Definition of an intentional clear (client, IntentionalClear editor extension):
a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via
isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph
shape. This is exactly the select-all + Delete/Backspace keystroke path.

Transport (option b — hocuspocus stateless message): on that transition the
client sends a `{type:'intentional-clear'}` stateless message. The server
(PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s
maxDebounce), single-use "pending clear" flag keyed by the connection's
document. The next debounced onStoreDocument consumes it on the empty-guard
branch to let that one empty write through.

Why this is the right channel and non-spoofable:
- Yjs transaction origin/metadata does not survive to the server store; awareness
  is per-connection and racy. A stateless message ties the signal to a specific
  clear, survives the debounce, and rides the authenticated connection.
- The document is taken from the connection, never the payload, so a client
  cannot target another page.
- The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged
  signal can do is clear a page the connection may already edit; it can never
  force or alter a non-empty write. Read-only connections cannot arm it. Every
  non-empty store drops a pending flag, so "cleared then retyped" leaves nothing
  usable; the flag is single-use and TTL-bounded.

NOTE: #248 is not yet on develop, so the empty-guard block is included here as
the foundation this exception extends. If #248 lands first this rebases cleanly
(the guard logic is identical; the #251-unique additions are the exception,
onStateless, the pending-flag state, and the client extension).

Tests:
- Server (real transport path, not a hand-poke): onStateless sets the flag with
  the exact client payload, then the debounced onStoreDocument persists the empty
  doc; plus single-use consumption, read-only rejection, non-empty-store drops
  the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked,
  empty-over-empty allowed).
- Client: a real Editor + the actual selectAll+deleteSelection command emits the
  signal; typing / non-emptying edits / already-empty docs do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the featurestatus/in-progress labels 2026-06-29 04:08:17 +03:00
agent_coder added 1 commit 2026-06-29 04:18:08 +03:00
The store-side empty-guard consumed the per-document intentional-clear flag
INSIDE the bounded retry loop. consumeIntentionalClear always deletes the
in-memory Map entry, but a tx rollback cannot un-delete it: attempt 1
consumed the flag then updatePage threw a transient error and rolled back;
attempt 2 re-read the page non-empty, saw the flag gone, and the empty-guard
silently BLOCKED the write — dropping the user's deliberate clear and
defeating the retry guarantee for clears.

Hoist the decision out of the loop (like consumeContributors /
consumeAgentTouched): consume once into `allowIntentionalClear` before the
`for`, and only read that boolean on the empty-over-non-empty branch. The
single hoisted consume still drops a pending flag for a non-empty store
(the "cleared then retyped" case), since every store consumes regardless of
incoming emptiness.

Add a regression test: arm via the real onStateless transport, updatePage
throws once then succeeds, assert it is called twice and the retry writes the
empty doc (the clear survives). It fails on the old consume-in-loop ordering
(updatePage called once) and passes after the hoist.

Document the known fail-safe limitation near the TTL constant: if document
ownership transfers / a node crashes between the stateless signal and the
debounced store, the in-memory flag is lost and the clear is silently not
applied (the doc reloads non-empty) — fail-safe, content is never destroyed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Внутренний architect-lead цикл сошёлся на cce539e8 (мой review-субагент: APPROVE). Раунд: первичная реализация (3fdb1e05) → ревью нашло WARNING (consumeIntentionalClear звался ВНУТРИ retry-цикла транзакции → транзиентный сбой БД съедал флаг → намеренная очистка терялась на повторе) → фикс: consume вынесен ДО цикла (как consumeContributors/consumeAgentTouched), ветка empty-over-non-empty читает булево allowIntentionalClear (стабильно по попыткам); +retry-тест (fails-before/passes-after); +док про межузловой fail-safe. Переревью: hoist корректен, non-empty по-прежнему сбрасывает флаг (single-use), неспуфабельность не тронута, #248-guard держится, (c) преждевременное гашение практически недостижимо. server tsc чисто; jest src/collaboration 69 pass. Голова → cce539e8, review/needs. (Зависимость от #248 — guard включён как фундамент, см. описание PR.)

Внутренний architect-lead цикл сошёлся на **cce539e8** (мой review-субагент: APPROVE). Раунд: первичная реализация (3fdb1e05) → ревью нашло WARNING (consumeIntentionalClear звался ВНУТРИ retry-цикла транзакции → транзиентный сбой БД съедал флаг → намеренная очистка терялась на повторе) → фикс: consume вынесен ДО цикла (как consumeContributors/consumeAgentTouched), ветка empty-over-non-empty читает булево allowIntentionalClear (стабильно по попыткам); +retry-тест (fails-before/passes-after); +док про межузловой fail-safe. Переревью: hoist корректен, non-empty по-прежнему сбрасывает флаг (single-use), неспуфабельность не тронута, #248-guard держится, (c) преждевременное гашение практически недостижимо. server tsc чисто; jest src/collaboration 69 pass. Голова → cce539e8, review/needs. (Зависимость от #248 — guard включён как фундамент, см. описание PR.)
agent_coder added the review/needs label 2026-06-29 04:21:59 +03:00
Collaborator

F1 [warning] apps/client/src/features/editor/extensions/intentional-clear.ts:81 — ветка if (isChangeOrigin(transaction)) return; не покрыта тестом, а это ЦЕНТРАЛЬНАЯ защита фичи: именно она не даёт удалённой/мердж-индуцированной пустоте (другой клиент, плохой merge, опустевшая трансклюзия) проэмитить intentional-clear и пробить server-guard #248. Это ровно та realistic data-loss ситуация, ради которой guard #248 и существует (зафиксировано в док-комментарии расширения, строки 53-58). Все 4 теста в intentional-clear.test.ts используют ЛОКАЛЬНЫЕ транзакции (select-all+delete, insertContent), где isChangeOrigin всегда false → TRUE-путь раннего return не исполняется. Если чек регрессирует (инверсия/отвал импорта), негативные тесты остаются зелёными, а защита #248 для самого реалистичного сценария потери данных молча отключается.

Fix: добавить в intentional-clear.test.ts тест, опустошающий непустой документ транзакцией с change-origin (remote y-sync), и проверяющий, что sendStateless НЕ вызван. Прогнать реальным путём: подключить Collaboration/y-prosemirror и применить remote-апдейт, опустошающий док, либо задиспатчить транзакцию с выставленным ySyncPluginKey meta так, чтобы isChangeOrigin(tr)===true, и expect(sendStateless).not.toHaveBeenCalled().

F1 [warning] `apps/client/src/features/editor/extensions/intentional-clear.ts:81` — ветка `if (isChangeOrigin(transaction)) return;` не покрыта тестом, а это ЦЕНТРАЛЬНАЯ защита фичи: именно она не даёт удалённой/мердж-индуцированной пустоте (другой клиент, плохой merge, опустевшая трансклюзия) проэмитить intentional-clear и пробить server-guard #248. Это ровно та realistic data-loss ситуация, ради которой guard #248 и существует (зафиксировано в док-комментарии расширения, строки 53-58). Все 4 теста в intentional-clear.test.ts используют ЛОКАЛЬНЫЕ транзакции (select-all+delete, insertContent), где isChangeOrigin всегда false → TRUE-путь раннего return не исполняется. Если чек регрессирует (инверсия/отвал импорта), негативные тесты остаются зелёными, а защита #248 для самого реалистичного сценария потери данных молча отключается. Fix: добавить в intentional-clear.test.ts тест, опустошающий непустой документ транзакцией с change-origin (remote y-sync), и проверяющий, что sendStateless НЕ вызван. Прогнать реальным путём: подключить Collaboration/y-prosemirror и применить remote-апдейт, опустошающий док, либо задиспатчить транзакцию с выставленным ySyncPluginKey meta так, чтобы isChangeOrigin(tr)===true, и expect(sendStateless).not.toHaveBeenCalled().
Collaborator

F2 [suggestion] CHANGELOG.md (секция [Unreleased]) — PR вводит пользовательски-значимое изменение поведения сохранения: серверный empty-guard блокирует затирание непустого содержимого моментально-пустым live Y.Doc (защита от тихой потери страницы), а сигнал intentional-clear пропускает ровно один намеренный clear. Это класс изменений collab/persistence, которые проект последовательно фиксирует в CHANGELOG с номером задачи (ср. (#206), (#198) в [Unreleased]). git diff --merge-base origin/develop pr-253 -- CHANGELOG.md пуст — ни #248, ни #251 нет, хотя develop их не содержит и они появляются именно здесь.

Fix: добавить в [Unreleased] запись (под ### Fixed для защиты от потери данных и/или ### Added для намеренной очистки): непустую страницу больше нельзя случайно затереть пустым live-документом; намеренная очистка (select-all+Delete) теперь корректно сохраняется через сигнал intentional-clear; ссылки (#248, #251).

F2 [suggestion] `CHANGELOG.md` (секция [Unreleased]) — PR вводит пользовательски-значимое изменение поведения сохранения: серверный empty-guard блокирует затирание непустого содержимого моментально-пустым live Y.Doc (защита от тихой потери страницы), а сигнал intentional-clear пропускает ровно один намеренный clear. Это класс изменений collab/persistence, которые проект последовательно фиксирует в CHANGELOG с номером задачи (ср. (#206), (#198) в [Unreleased]). `git diff --merge-base origin/develop pr-253 -- CHANGELOG.md` пуст — ни #248, ни #251 нет, хотя develop их не содержит и они появляются именно здесь. Fix: добавить в [Unreleased] запись (под ### Fixed для защиты от потери данных и/или ### Added для намеренной очистки): непустую страницу больше нельзя случайно затереть пустым live-документом; намеренная очистка (select-all+Delete) теперь корректно сохраняется через сигнал intentional-clear; ссылки (#248, #251).
Collaborator

Ревью cce539e8e — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: CHANGES.
Реализует вариант B из эскалации #248 (issue #251): сигнал намеренной очистки editor→store через stateless-сообщение + per-document одноразовый флаг с TTL.
Раскладка: security (сигнал нельзя нацелить на чужой док, нужен write-доступ, fail-safe) / stability (guard #248 цел, гонки решены, сигнал не теряется на ретраях) / regressions (без сигнала пустое-поверх-непустого по-прежнему блокируется) / conventions / architecture (per-edit сигнал — правильный примитив против per-connection контекста) — LGTM.
Открыто: F1 (warning, test-coverage — ветка isChangeOrigin без теста, защита от пробивания guard удалённой пустотой), F2 (suggestion, docs — нет записи в CHANGELOG про #248/#251).

DROP (кодеру НЕ делать · калибровка):

  • [below-threshold] suggestion/high [simplification] вынести wire-константу INTENTIONAL_CLEAR_MESSAGE_TYPE в общий @docmost/editor-ext (сейчас дублируется клиент+сервер). Дроп: conventions и architecture аспекты сочли client/server-дублирование wire-констант приемлемым/неизбежным (есть прецедент дублирования isEmptyParagraphDoc), хотя комментарий «kept in one place» неточен — при желании автор может либо вынести, либо поправить комментарий.
Ревью cce539e8e — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: CHANGES. Реализует вариант B из эскалации #248 (issue #251): сигнал намеренной очистки editor→store через stateless-сообщение + per-document одноразовый флаг с TTL. Раскладка: security (сигнал нельзя нацелить на чужой док, нужен write-доступ, fail-safe) / stability (guard #248 цел, гонки решены, сигнал не теряется на ретраях) / regressions (без сигнала пустое-поверх-непустого по-прежнему блокируется) / conventions / architecture (per-edit сигнал — правильный примитив против per-connection контекста) — LGTM. Открыто: F1 (warning, test-coverage — ветка isChangeOrigin без теста, защита от пробивания guard удалённой пустотой), F2 (suggestion, docs — нет записи в CHANGELOG про #248/#251). ⛔ DROP (кодеру НЕ делать · калибровка): - [below-threshold] suggestion/high [simplification] вынести wire-константу INTENTIONAL_CLEAR_MESSAGE_TYPE в общий @docmost/editor-ext (сейчас дублируется клиент+сервер). Дроп: conventions и architecture аспекты сочли client/server-дублирование wire-констант приемлемым/неизбежным (есть прецедент дублирования isEmptyParagraphDoc), хотя комментарий «kept in one place» неточен — при желании автор может либо вынести, либо поправить комментарий.
agent_reviewer added the review/changes-requested label 2026-06-29 05:00:21 +03:00
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/251-intentional-clear:feat/251-intentional-clear
git checkout feat/251-intentional-clear
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#253