fix(git-sync): unwedge per-page conflicts, preserve callout types, flush collab on disconnect

Addresses QA findings on PR #119 (issues #235/#236).

SYNC-WEDGE (HIGH): one same-line conflict on one page froze sync for the
WHOLE space in both directions forever. The pull's docmost->main merge left
the vault mid-merge, so every later cycle's isMergeInProgress() check returned
skipped:"merge-in-progress" and skipped the entire space with no recovery.
- pull.ts now COMMITS a conflicting merge with markers in place (commitMerge):
  cleanly-merged pages land, the conflicted page carries its markers on main and
  is isolated by the existing push-side conflict-marker skip (markers never reach
  Docmost), and the next cycle is no longer wedged. conflictedPaths is surfaced.
- cycle.ts now RECOVERS a vault left mid-merge by a prior/pre-fix cycle: it
  aborts the stale merge (merge --abort, hard-reset fallback) and continues,
  instead of skipping the space forever.
- git.ts: listUnmergedPaths / commitMerge / abortMerge / resetHardToHead.

CALLOUT TYPE FIDELITY: git-sync's CALLOUT_TYPES was missing "note" and "default"
(editor-canonical types), so [!note]/[!default] callouts flattened to [!info] on
every round-trip. Aligned the list with @docmost/editor-ext getValidCalloutType.

LOSS-ON-FAST-CLOSE: editing a page then closing the tab inside the collab
debounce window (~3-18s) lost the edit, because with unloadImmediately:false
Hocuspocus does not flush the debounced onStoreDocument on the last-client
disconnect. PersistenceExtension.onDisconnect now flushes the pending store
(debouncer.executeNow) on the last disconnect only, with no redundant write.

DUPLICATION re-verify (#1): the schema-default merge-key normalization is intact;
faithful toYdoc-based reproduction shows callout + rich content resync with 0 ops
and no growth/strip across cycles -> the re-report was leftover vault data, not a
live regression. Locked with a callout regression spec.

Tests: git-sync 688 pass (incl. real-VaultGit wedge-recovery integration); server
git-sync+collaboration 285 pass; new callout merge/fidelity + onDisconnect-flush
specs. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
a
2026-06-27 19:50:28 +03:00
committed by claude code agent 227
parent eefe17600c
commit fe4adf23a0
11 changed files with 517 additions and 28 deletions

View File

@@ -0,0 +1,89 @@
import { PersistenceExtension } from './persistence.extension';
/**
* Regression for the QA #119 "loss-on-fast-close" data loss: editing a page then
* closing the tab within the collab debounce window (~3-18s) lost the edit
* because, with `unloadImmediately: false`, Hocuspocus does NOT flush the
* debounced onStoreDocument on a last-client disconnect. PersistenceExtension
* now flushes the pending store on the LAST disconnect (and only then).
*/
describe('PersistenceExtension.onDisconnect flush (loss-on-fast-close)', () => {
function makeExt(): PersistenceExtension {
// onDisconnect touches none of the injected deps; pass casts.
return new PersistenceExtension(
null as any,
null as any,
null as any,
null as any,
null as any,
null as any,
null as any,
null as any,
);
}
function makeData(opts: {
clientsCount: number;
isDebounced: boolean;
isLoading?: boolean;
}) {
const executeNow = jest.fn(async () => undefined);
const isDebounced = jest.fn(() => opts.isDebounced);
return {
executeNow,
isDebounced,
payload: {
clientsCount: opts.clientsCount,
context: {},
document: { isLoading: opts.isLoading ?? false } as any,
documentName: 'page.abc',
instance: { debouncer: { isDebounced, executeNow } } as any,
requestHeaders: {},
requestParameters: new URLSearchParams(),
socketId: 's',
} as any,
};
}
it('flushes the pending store when the LAST client disconnects', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 0,
isDebounced: true,
});
await ext.onDisconnect(payload);
expect(executeNow).toHaveBeenCalledTimes(1);
expect(executeNow).toHaveBeenCalledWith('onStoreDocument-page.abc');
});
it('does NOT flush while other editors remain connected', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 2,
isDebounced: true,
});
await ext.onDisconnect(payload);
expect(executeNow).not.toHaveBeenCalled();
});
it('does NOT write when nothing is pending (already persisted)', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 0,
isDebounced: false,
});
await ext.onDisconnect(payload);
expect(executeNow).not.toHaveBeenCalled();
});
it('does NOT flush a doc that is still loading (load error guard)', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 0,
isDebounced: true,
isLoading: true,
});
await ext.onDisconnect(payload);
expect(executeNow).not.toHaveBeenCalled();
});
});

View File

@@ -2,6 +2,7 @@ import {
afterUnloadDocumentPayload,
Extension,
onChangePayload,
onDisconnectPayload,
onLoadDocumentPayload,
onStoreDocumentPayload,
} from '@hocuspocus/server';
@@ -164,6 +165,40 @@ export class PersistenceExtension implements Extension {
return new Y.Doc();
}
/**
* LOSS-ON-FAST-CLOSE FIX (QA #119). When the LAST editor disconnects, FLUSH any
* pending (debounced) store to the DB IMMEDIATELY instead of waiting out the
* up-to-10s `debounce` window.
*
* The collab server runs with `unloadImmediately: false` (collaboration.gateway),
* so on a last-client disconnect Hocuspocus does NOT flush the debounced
* onStoreDocument — it relies on the timer firing later. A quick edit-then-close
* (closing the tab within the debounce window, ~3-18s) therefore left the edit
* only in the soon-to-be-unloaded in-memory Y.Doc; meanwhile git-sync mirrored
* the STALE/empty DB body to the vault (the reported "59-byte frontmatter-only"
* data loss). Running the already-scheduled store now closes that window.
*
* Gated tightly so it never adds a redundant write: only on the LAST disconnect
* (`clientsCount === 0`), only for a fully-loaded doc, and only when a store is
* actually pending (`isDebounced`). `executeNow` runs the SAME payload Hocuspocus
* scheduled (preserving the edit's context/actor) and clears the timer.
*/
async onDisconnect(data: onDisconnectPayload) {
const { instance, document, documentName, clientsCount } = data;
if (clientsCount > 0) return;
if (!document || document.isLoading) return;
const debounceId = `onStoreDocument-${documentName}`;
if (!instance?.debouncer?.isDebounced(debounceId)) return;
try {
await instance.debouncer.executeNow(debounceId);
} catch (err) {
this.logger.error(
`onDisconnect flush failed for ${documentName}: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;

View File

@@ -0,0 +1,161 @@
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
import {
markdownToProseMirror,
convertProseMirrorToMarkdown,
} from '@docmost/git-sync';
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
/**
* Regression for the QA #119 callout findings (body-duplication re-verify +
* "callout strips the whole body"). These reproduce the ACTUAL live merge path:
*
* live = TiptapTransformer.toYdoc(editor JSON, tiptapExtensions) (the
* collaboration server's materialization — schema defaults stamped)
* git = toYdoc(markdownToProseMirror(convertProseMirrorToMarkdown(editor)))
* (the engine round-trip the push side feeds into writePageBody)
*
* A page containing a callout (with a neighbouring heading + paragraphs) must:
* - merge with ZERO ops on an unchanged resync (no duplication — bug #1), and
* - NEVER lose blocks / collapse to empty (no strip — bug #2),
* across repeated cycles, for every editor-canonical callout type.
*/
const toYdoc = (content: unknown[]) =>
TiptapTransformer.toYdoc(
{ type: 'doc', content },
'default',
tiptapExtensions as any,
);
const blockTypes = (f: Y.XmlFragment) =>
f.toArray().map((n: any) => n.nodeName);
function editorPage(calloutType: string) {
return [
{
type: 'heading',
attrs: { id: 'h1', level: 1 },
content: [{ type: 'text', text: 'Title here' }],
},
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [{ type: 'text', text: 'Para before callout' }],
},
{
type: 'callout',
attrs: { type: calloutType },
content: [
{
type: 'paragraph',
attrs: { id: 'pc' },
content: [{ type: 'text', text: 'Inside the callout' }],
},
],
},
{
type: 'paragraph',
attrs: { id: 'p2' },
content: [{ type: 'text', text: 'Para after callout' }],
},
];
}
async function gitRoundTrip(content: unknown[]): Promise<any[]> {
const md = await convertProseMirrorToMarkdown({ type: 'doc', content });
const json = await markdownToProseMirror(md);
return json.content;
}
describe('git-sync callout merge is idempotent + non-destructive (QA #119)', () => {
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
it(`callout(${type}) resyncs with 0 ops and never strips the body`, async () => {
const editor = editorPage(type);
const gitContent = await gitRoundTrip(editor);
const liveDoc = toYdoc(editor);
const live = liveDoc.getXmlFragment('default');
const before = live.toArray().length;
expect(before).toBe(4);
// 2-way: live vs the git round-trip -> no-op (no dup, no strip).
let applied = -1;
liveDoc.transact(() => {
applied = mergeXmlFragments(live, toYdoc(gitContent).getXmlFragment('default'));
});
expect(applied).toBe(0);
expect(live.toArray().length).toBe(before);
// 3-way across 4 cycles with base == git (the steady-state) -> stable.
for (let cycle = 0; cycle < 4; cycle++) {
let a = -1;
liveDoc.transact(() => {
a = mergeXmlFragments3Way(
live,
toYdoc(gitContent).getXmlFragment('default'),
toYdoc(gitContent).getXmlFragment('default'),
);
});
expect(a).toBe(0);
expect(live.toArray().length).toBe(before);
expect(blockTypes(live)).toEqual([
'heading',
'paragraph',
'callout',
'paragraph',
]);
}
});
}
it('3-way with a stale base (callout JUST added) keeps the callout + neighbours', async () => {
// base = the previously-synced version WITHOUT the callout (git round-trip);
// the human just inserted the callout -> the merge must KEEP everything.
const prev = [
{ type: 'heading', attrs: { id: 'h1', level: 1 }, content: [{ type: 'text', text: 'Title here' }] },
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'Para before callout' }] },
{ type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'Para after callout' }] },
];
const editor = editorPage('info');
const baseContent = await gitRoundTrip(prev);
const gitContent = await gitRoundTrip(editor);
const liveDoc = toYdoc(editor);
const live = liveDoc.getXmlFragment('default');
liveDoc.transact(() => {
mergeXmlFragments3Way(
live,
toYdoc(gitContent).getXmlFragment('default'),
toYdoc(baseContent).getXmlFragment('default'),
);
});
// Body survives in full — NOT stripped to empty / a lone paragraph.
expect(blockTypes(live)).toEqual([
'heading',
'paragraph',
'callout',
'paragraph',
]);
});
});
describe('git-sync callout type fidelity (QA "callout type -> [!info]")', () => {
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
it(`preserves callout type "${type}" across the engine round-trip`, async () => {
const content = editorPage(type);
const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe(type);
});
}
it('flattens a genuinely unknown callout type to info', async () => {
const content = editorPage('tip'); // not an editor-canonical type
const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe('info');
});
});