fix(git-sync): merge git body into the live doc block-by-block (no clobber)

Supersedes the active-session "defer" guard with a real merge (review #5 —
"запись делать через мерж", not skip-while-editing).

writeBody no longer does delete-all + re-insert (which discarded a concurrent
editor's in-flight changes on every sync). It now diffs the live body against the
incoming git body at TOP-LEVEL BLOCK granularity (LCS over a canonical structural
serialization) and applies only the minimal inserts/deletes:
- a block a human is editing is left UNTOUCHED when git changed a DIFFERENT block;
- an unchanged resync is a complete 0-op write;
- Yjs CRDT-merges the minimal ops with concurrent edits.

New yjs-body-merge.ts (mergeXmlFragments + cloneXmlNode + diffBlocks) is pure-Yjs
and unit-tested with real Y.Docs (8 tests): identical->0 ops, edit-one-block keeps
the other block instances, append/delete keep neighbours, marks survive the
cross-doc clone. Crash-safety kept: the incoming doc is built before the
connection opens, so a transform failure can't empty the body.

Removed: the ActiveEditSessionError defer path and the now-unused
CollaborationGateway.getActiveEditorCount.

Honest limitation: this is a 2-way merge — for a block BOTH sides changed since the
last sync, git wins (no common ancestor to decide). A full 3-way merge would need
the last-synced base plumbed from the engine; the dominant cases (unchanged
resync, edits to different blocks) are now lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-23 15:21:14 +03:00
parent 97b52ba397
commit 59d1cd1883
5 changed files with 350 additions and 90 deletions

View File

@@ -1,5 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import {
@@ -15,6 +14,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PageService } from '../../../core/page/services/page.service';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
import { mergeXmlFragments } from './yjs-body-merge';
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
/**
@@ -42,23 +42,6 @@ const GIT_SYNC_PROVENANCE: AuthProvenanceData = {
aiChatId: null,
};
/**
* Thrown when a git -> page body write is skipped because a human is editing the
* page RIGHT NOW (a live collab session). The engine's push loop catches this
* per page, records it as a (non-fatal) failure, and does NOT advance the
* loop-guard for that page — so the write is retried on the next poll once the
* editor disconnects, instead of clobbering their in-flight edits with a
* full-body replace (plan §15.6 / review #5).
*/
export class ActiveEditSessionError extends Error {
constructor(pageId: string) {
super(
`git-sync: page ${pageId} has an active edit session; deferring body write`,
);
this.name = 'ActiveEditSessionError';
}
}
/**
* Native, in-process implementation of the engine's `GitSyncClient` seam
* (plan §3). Reads go through repositories (PageRepo/SpaceRepo); body writes go
@@ -398,28 +381,15 @@ export class GitmostDataSourceService {
): Promise<void> {
const documentName = `page.${pageId}`;
// Do NOT clobber a page someone is editing right now. The write below is a
// full-body replace (delete-all + re-insert); applied over a live editing
// session it would discard the user's in-flight changes. If a human editor
// is connected, defer: throw so the engine retries on the next poll once
// they disconnect (review #5 — "не писать в страницу с активной сессией").
if (this.collabGateway.getActiveEditorCount(documentName) > 0) {
this.logger.debug(
`Skipping git-sync body write for ${documentName}: active edit session`,
);
throw new ActiveEditSessionError(pageId);
}
// Build the replacement Yjs state BEFORE touching the live doc. If the
// transform throws (a malformed/unsupported doc), we must NOT have already
// cleared the fragment — otherwise a conversion failure would leave the page
// with an empty body (review #5 — crash-safe conversion).
const next = TiptapTransformer.toYdoc(
// Build the incoming Yjs doc BEFORE opening the connection / touching the
// live doc. If the transform throws (a malformed/unsupported doc) we must NOT
// have mutated the live body — otherwise a conversion failure could leave the
// page empty (review #5 — crash-safe conversion).
const targetDoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
const update = Y.encodeStateAsUpdate(next);
const conn = await this.collabGateway.openDirectConnection(documentName, {
actor: 'git-sync',
@@ -430,9 +400,15 @@ export class GitmostDataSourceService {
});
try {
await conn.transact((doc) => {
const fragment = doc.getXmlFragment('default');
if (fragment.length > 0) fragment.delete(0, fragment.length);
Y.applyUpdate(doc, update);
// Block-level MERGE rather than a full-body replace (review #5): diff the
// live body against the incoming git body and apply only the blocks that
// actually changed. Blocks a human is concurrently editing — anything git
// did not change — are left untouched, and an unchanged resync is a 0-op
// write. Yjs CRDT-merges the minimal ops with live edits.
mergeXmlFragments(
doc.getXmlFragment('default'),
targetDoc.getXmlFragment('default'),
);
});
} finally {
await conn.disconnect();