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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user