fix(footnotes): survive duplicate-id definitions without collab divergence

Release-cycle red-team found two same-id footnoteDefinition nodes (trivially
produced by markdown import [^d]: first / [^d]: second, or paste/duplicate)
caused silent data loss: scan() used a last-wins Map and the sync rebuild
(addToHistory:false, propagated via Yjs, un-undoable) dropped all but the last.

Fix resolves collisions so BOTH survive, with a DETERMINISTIC id scheme so
collaborators converge:
- deriveFootnoteId(originalId, occurrence, taken): the k-th (k>=2) occurrence of
  id X becomes X__k, bumped with a deterministic alpha suffix only against the
  doc's own id set — a pure function of document state. No Math.random/Date.now
  on the sync or import paths (random uuid stays only in setFootnote, where a
  single user originates a brand-new id).
- footnote-sync.resolveCollisions walks refs+defs in document order, re-ids
  duplicate references via setNodeMarkup and pairs them 1:1 with definitions;
  single SYNC_META-tagged transaction, returns null when canonical (terminates).
- Markdown import (footnote.marked) + MCP mirror (collaboration.ts) dedup with
  the same deterministic scheme + marker rewrite; packages/mcp/build regenerated.
- Paste plugin remaps colliding pasted ids against the current doc.

Tests: two independent editors resolving the same duplicate-id doc produce
IDENTICAL ids (the cross-client determinism guard that the random version would
fail); both definitions survive the first edit; import dedup is deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 13:47:10 +03:00
parent 1c83a8ae15
commit ceee2a76ca
9 changed files with 864 additions and 25 deletions

View File

@@ -8,7 +8,7 @@ import {
generateFootnoteId,
} from "./footnote-util";
import { footnoteNumberingPlugin } from "./footnote-numbering";
import { footnoteSyncPlugin } from "./footnote-sync";
import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync";
export interface FootnoteReferenceOptions {
HTMLAttributes: Record<string, any>;
@@ -88,6 +88,9 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
// doc is never mutated.
if (this.options.enableSync !== false) {
plugins.push(footnoteSyncPlugin(this.options.isRemoteTransaction));
// Regenerate colliding footnote ids on paste so a pasted reference+
// definition pair never clobbers/merges with an existing footnote.
plugins.push(footnotePastePlugin());
}
return plugins;
},