fix(#244): two HIGH data-loss bugs — lossless markdown export + store-side empty-guard #248

Open
Ghost wants to merge 5 commits from fix/244-dataloss-bugs into develop

Долг #244 Часть A — два HIGH-бага потери данных

Закрывает Часть A issue #244: два подтверждённых бага потери данных, которые PR #230 осознанно оставил незафиксированными (только характеризующие it.fails/it.failing). Теперь это настоящие фиксы, тесты зелёные.

mdrt-2 — экспорт в Markdown молча выкидывал кастом-узлы

turndown.utils.ts: добавлены 4 правила (pageBreak, transclusionReference, mention, status) + предобработка. Две причины: (1) blank-node removal в turndown использует module-level isVoid (опцией не переопределить) → бездетные atom-<div> (pageBreak/transclusionReference) попадали в blankRule и исчезали → починено fillEmptyAtomBlocks (zero-width space, как существующий fillEmptyFootnoteRefs); (2) mention/status не пустые, но падали в дефолт-правило, теряя data-id/data-color. Lossless-представление: каждый узел реэмитится как инертный raw-HTML из ВСЕХ атрибутов (serializeAttrs), inline — с экранированным текстом. Round-trip подтверждён (markdownToHtml пропускает raw-HTML, parseHTML каждого узла восстанавливает ноду).

persist-6 — пустой live-doc затирал непустой контент

persistence.extension.ts: store-side empty-guard в onStoreDocument (внутри транзакции, после unchanged-short-circuit, до saveHistory/updatePage): если isEmptyParagraphDoc И page.content непустой И нет context.intentionalClear → warn + skip записи. Зеркалит guard из onLoadDocument. Легитимные очистки не сломаны: intentionalClear===true пишет; новые/пустые страницы не затронуты (guard требует непустой существующий контент); empty-over-empty отсекается раньше как deep-equal.

Тесты

editor-ext vitest 197 pass (turndown.dataloss 8/8, no .fails); server persistence-store 9/9 (no .failing) + intentional-clear escape + empty-over-empty; collaboration 65, import round-trip 59. editor-ext + server tsc чисто.

Часть B (большой тест-бэклог) — НЕ в этом PR, остаётся открытой в #244.

🤖 Generated with Claude Code

## Долг #244 Часть A — два HIGH-бага потери данных Закрывает Часть A issue #244: два подтверждённых бага потери данных, которые PR #230 осознанно оставил незафиксированными (только характеризующие `it.fails`/`it.failing`). Теперь это настоящие фиксы, тесты зелёные. ### mdrt-2 — экспорт в Markdown молча выкидывал кастом-узлы `turndown.utils.ts`: добавлены 4 правила (`pageBreak`, `transclusionReference`, `mention`, `status`) + предобработка. Две причины: (1) blank-node removal в turndown использует module-level `isVoid` (опцией не переопределить) → бездетные atom-`<div>` (pageBreak/transclusionReference) попадали в blankRule и исчезали → починено `fillEmptyAtomBlocks` (zero-width space, как существующий `fillEmptyFootnoteRefs`); (2) mention/status не пустые, но падали в дефолт-правило, теряя `data-id`/`data-color`. Lossless-представление: каждый узел реэмитится как инертный raw-HTML из ВСЕХ атрибутов (`serializeAttrs`), inline — с экранированным текстом. Round-trip подтверждён (markdownToHtml пропускает raw-HTML, `parseHTML` каждого узла восстанавливает ноду). ### persist-6 — пустой live-doc затирал непустой контент `persistence.extension.ts`: store-side empty-guard в `onStoreDocument` (внутри транзакции, после unchanged-short-circuit, до saveHistory/updatePage): если `isEmptyParagraphDoc` И `page.content` непустой И нет `context.intentionalClear` → warn + skip записи. Зеркалит guard из `onLoadDocument`. Легитимные очистки не сломаны: `intentionalClear===true` пишет; новые/пустые страницы не затронуты (guard требует непустой существующий контент); empty-over-empty отсекается раньше как deep-equal. ### Тесты editor-ext vitest 197 pass (turndown.dataloss 8/8, no .fails); server persistence-store 9/9 (no .failing) + intentional-clear escape + empty-over-empty; collaboration 65, import round-trip 59. editor-ext + server tsc чисто. Часть B (большой тест-бэклог) — НЕ в этом PR, остаётся открытой в #244. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: 90a3fa012d65654477d35b20f7ad6fe285f67dc1 baseline_head: 90a3fa012d65654477d35b20f7ad6fe285f67dc1 verdict: approved round: 6 max_rounds: 6 open_findings: [] reopened: {} -->
Ghost added 1 commit 2026-06-28 14:49:59 +03:00
mdrt-2 (markdown export): add lossless turndown rules for the custom nodes
that had no rule — transclusionReference, pageBreak, mention, status. Each
re-emits the node as inert raw HTML carrying every data-* attribute instead
of being silently dropped (childless atom divs) or collapsed to bare text
(mention/status losing data-id/data-color). Empty atom blocks are made
non-blank before turndown's blank-rule strips them (mirrors the footnote-ref
fix). markdownToHtml passes the raw HTML through and each node's parseHTML
rebuilds it, so the form round-trips. Flips the it.fails cases to passing and
adds export + import round-trip coverage.

persist-6 (collab store): add a store-side empty-guard in onStoreDocument.
Before updatePage, if the serialized live doc is an empty paragraph doc AND
the persisted page is non-empty, skip the write and log — unless an explicit
context.intentionalClear signal is present (deliberate select-all+delete).
New/empty pages and unchanged docs are unaffected. Flips the it.failing case
to passing and adds escape-hatch + empty-over-empty coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added the needs-human label 2026-06-28 22:06:16 +03:00
Ghost added review/changes-requested and removed needs-human labels 2026-06-28 22:11:27 +03:00
Ghost added 2 commits 2026-06-28 23:46:22 +03:00
Round-1 review F1 (maintainer decision: variant A). The store-side
empty-guard's `context?.intentionalClear === true` branch was dead:
`intentionalClear` is never set in production (connection context is
{user, actor, aiChatId}); it appeared only in the guard and a hand-injected
spec, so the guard already blocked empty-over-non-empty unconditionally.

- persistence.extension.ts: drop the dead branch; the guard now simply
  skips empty-over-non-empty, full stop. Reference issue #251 (real
  intentional-clear UX) in the comment where the branch was.
- persistence-store.spec.ts: remove the misleading "persists an intentional
  clear" escape-hatch test (false coverage — green only because the flag was
  injected by hand). Real guard tests (empty-over-empty allowed,
  empty-over-non-empty blocked, etc.) kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-1 review F2. The escapeHtmlAttr (&,",<,>) and escapeHtmlText (&,<,>)
helpers in turndown.utils were untested — every existing round-trip case used
alphanumeric values, so no escape branch ran. A mention/status carrying HTML
special chars would re-emit malformed HTML that import's parseHTML can't
restore → the same data loss this PR fixes, uncaught.

Add a round-trip case to turndown.dataloss.test.ts: a mention with `&` and `"`
in both data-label and visible text. Assert (a) the exported Markdown carries
the correctly-escaped, well-formed tag (data-label="A &amp; &quot;B&quot;",
text escapes &), not the raw malformed form; and (b) markdownToHtml restores
the original unescaped values (attribute `A & "B"`, text `@A & "B"`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-29 00:05:21 +03:00
The escaping round-trip test's data (A & "B") only contained & and ",
so the <,> branches of escapeHtmlAttr (&,",<,>) and escapeHtmlText (&,<,>)
were never exercised; a regression dropping <,> escaping would still pass.
Extend the data to A & <B> "C" in both the data-label attribute and the
visible text so both functions' <,> branches are genuinely covered. Assert
the well-formed escaped tag (attr: A &amp; &lt;B&gt; &quot;C&quot;, text:
A &amp; &lt;B&gt; "C"), explicitly reject the raw tag-corrupting forms,
and confirm markdownToHtml restores the originals. Comment updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added status/in-progressreview/needs and removed review/changes-requested labels 2026-06-29 00:17:45 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 00:27:16 +03:00
Ghost added review/changes-requested and removed review/approved labels 2026-06-29 00:59:29 +03:00
Ghost added 1 commit 2026-06-29 01:56:23 +03:00
The "does not block an empty store over an already-empty page" test set the
stored page.content to TiptapTransformer.fromYdoc(document,'default') — exactly
the value tiptapJson is computed from — so isDeepStrictEqual(tiptapJson,
page.content) was TRUE and onStoreDocument RETURNED at the unchanged short-circuit
before ever reaching the empty-guard. It exercised the old short-circuit, not the
new guard's `!isEmptyParagraphDoc(page.content)` branch (the only NEW branch
protecting empty existing pages from over-blocking); the condition could be
removed and the test would still pass (false coverage).

Set stored content to an empty paragraph with `content: []` — empty per
isEmptyParagraphDoc but NOT deep-equal to the live doc (which normalizes to a
paragraph with `attrs: { indent: 0 }` and no content key). Execution now skips
the short-circuit and enters the guard; reorient the assertion to "the write is
NOT blocked" (updatePage IS called). Verified the test now FAILS if the
`!isEmptyParagraphDoc(page.content)` condition is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/approved and removed review/changes-requested labels 2026-06-29 01:59:19 +03:00
Collaborator

Ревью 90a3fa012 — переревью ПОЛНЫМИ 8 аспектами (отдельный субагент на каждый: security, stability, regressions, test-coverage, conventions, documentation, simplification, architecture). Вердикт: approved.
Все 8 аспектов — LGTM. Подтверждено: XSS/инъекций нет (экранирование атрибутов корректно, импорт через schema/DOMPurify), empty-guard в правильном слое (в транзакции под lock, не глотает легитимное, empty-over-empty доходит до guard и проходит), round-trip lossless и обратно-совместим для документов без новых узлов, оба бага покрыты регрессионными тестами (падали бы на pre-fix), guard-комментарии и ссылка на #251 точны. Открытых находок нет.

Ревью 90a3fa012 — переревью ПОЛНЫМИ 8 аспектами (отдельный субагент на каждый: security, stability, regressions, test-coverage, conventions, documentation, simplification, architecture). Вердикт: approved. Все 8 аспектов — LGTM. Подтверждено: XSS/инъекций нет (экранирование атрибутов корректно, импорт через schema/DOMPurify), empty-guard в правильном слое (в транзакции под lock, не глотает легитимное, empty-over-empty доходит до guard и проходит), round-trip lossless и обратно-совместим для документов без новых узлов, оба бага покрыты регрессионными тестами (падали бы на pre-fix), guard-комментарии и ссылка на #251 точны. Открытых находок нет.
This pull request can be merged automatically.
This branch is out-of-date with the base branch
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 fix/244-dataloss-bugs:fix/244-dataloss-bugs
git checkout fix/244-dataloss-bugs
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#248