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