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 94f5479031
commit e53b962c68
5 changed files with 350 additions and 90 deletions

View File

@@ -32,10 +32,7 @@ jest.mock('@docmost/editor-ext', () => ({
}));
import * as Y from 'yjs';
import {
GitmostDataSourceService,
ActiveEditSessionError,
} from './gitmost-datasource.service';
import { GitmostDataSourceService } from './gitmost-datasource.service';
// Focused unit/contract test for the native GitSyncClient adapter (plan §3).
// No DB, no real collab server: the repos/services/gateway are mocked and we
@@ -56,10 +53,7 @@ interface Mocks {
movePage: AnyMock;
removePage: AnyMock;
};
collabGateway: {
openDirectConnection: AnyMock;
getActiveEditorCount: AnyMock;
};
collabGateway: { openDirectConnection: AnyMock };
// Minimal Kysely-ish chainable mock for the direct-query paths.
db: any;
// Captured collab connection (the fake conn the gateway returns).
@@ -110,8 +104,6 @@ function build(rows: any[] = []): {
conn.context = ctx;
return conn;
}),
// Default: no live editor sessions, so body writes proceed.
getActiveEditorCount: jest.fn(() => 0),
},
db: {
selectFrom: jest.fn(() => makeQueryBuilder(rows)),
@@ -247,31 +239,11 @@ describe('GitmostDataSourceService', () => {
expect(res.updatedAt).toBe('2026-06-20T11:00:00.000Z');
});
it('defers (throws ActiveEditSessionError) when a human is editing the page — never clobbers', async () => {
const { service, mocks } = build();
// A live editor session on this page.
mocks.collabGateway.getActiveEditorCount.mockReturnValue(1);
await expect(
service.bind(CTX).importPageMarkdown('p1', '# Hello\n\nworld'),
).rejects.toBeInstanceOf(ActiveEditSessionError);
// The destructive full-body write must NOT have happened: no connection
// opened, no transact run. The engine's push loop catches this and retries
// on the next poll once the editor disconnects.
expect(mocks.collabGateway.getActiveEditorCount).toHaveBeenCalledWith(
'page.p1',
);
expect(mocks.collabGateway.openDirectConnection).not.toHaveBeenCalled();
expect(mocks.conn.transact).not.toHaveBeenCalled();
});
it('crash-safe: the captured write applies real content (update built before clearing)', async () => {
// The replacement Yjs update is computed BEFORE the connection opens / the
// fragment is cleared, so a transform failure can never leave the body
// emptied. Here we run the captured transact callback against a REAL Y.Doc
// and confirm it ends up with content (the precomputed update is valid and
// applied), i.e. the write produces a non-empty body rather than wiping it.
it('crash-safe: the incoming doc is built before the connection opens, and the captured merge applies content', async () => {
// The incoming Yjs doc is built BEFORE the connection opens, so a transform
// failure can never mutate the live body. Here we run the captured transact
// callback (the block-level merge) against a REAL empty Y.Doc and confirm it
// ends up with content — the write produces a non-empty body, never wipes it.
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue({
id: 'p1',
@@ -281,7 +253,7 @@ describe('GitmostDataSourceService', () => {
const realDoc = new Y.Doc();
expect(() => mocks.conn.capturedFn?.(realDoc)).not.toThrow();
// The body fragment is non-empty: the markdown was converted and applied.
// The body fragment is non-empty: the incoming block was merged in.
expect(realDoc.getXmlFragment('default').length).toBeGreaterThan(0);
});
});