fix(git-sync): don't clobber pages with a live editing session; crash-safe body write
Review finding #5: the git -> page body write (writeBody) did a full-body replace
(delete-all + re-insert) on the shared Yjs doc. Applied while a human is editing
the page, it discarded their in-flight changes; and TiptapTransformer.toYdoc ran
AFTER the fragment was cleared, so a conversion failure could leave the page with
an empty body.
Fixes:
- Active-session guard: CollaborationGateway.getActiveEditorCount(documentName)
reports live human (websocket) editor sessions for a doc, excluding server-side
direct connections. writeBody now throws ActiveEditSessionError when an editor
is connected. The engine's push loop already isolates each importPageMarkdown in
try/catch and does not advance the loop-guard on failure, so the write is simply
retried on the next poll once the editor disconnects — never a clobber.
- Crash-safe conversion: build the replacement Yjs update BEFORE opening the
connection / clearing the fragment, so a transform failure can never leave the
body empty.
Also updates the server-side converter gate spec to the corrected round-trip
shape: the block-image hoist no longer leaves a leading empty paragraph (the
git-sync converter fix in 7d39c16b, now reaching the built package).
A true merge of git content into a live Yjs session is out of scope (it needs a
real 3-way text merge with no shared update lineage); deferring the write while a
page is being edited is the safe, owner-approved minimum.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,21 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.getDocumentsCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of LIVE human editor sessions (websocket connections) currently open
|
||||
* on a document, or 0 if the document is not loaded. Unlike
|
||||
* `Document.getConnectionsCount()` this deliberately excludes server-side
|
||||
* direct connections (`directConnectionsCount`, e.g. the git-sync writer
|
||||
* itself), so callers can tell whether a real person is editing right now.
|
||||
*
|
||||
* NOTE: this reflects only THIS instance. In a Redis-clustered deployment an
|
||||
* editor attached to another node is not counted; for the single-instance
|
||||
* deployments this guards (git-sync) that is exactly the live set.
|
||||
*/
|
||||
getActiveEditorCount(documentName: string): number {
|
||||
return this.hocuspocus.documents.get(documentName)?.connections.size ?? 0;
|
||||
}
|
||||
|
||||
handleYjsEvent<TName extends keyof CollabEventHandlers>(
|
||||
eventName: TName,
|
||||
documentName: string,
|
||||
|
||||
@@ -401,18 +401,19 @@ describe('git-sync converter §13.1 KNOWN DIVERGENCE (markdown image lossiness)'
|
||||
},
|
||||
});
|
||||
|
||||
it('drops width/height/align (markdown  cannot carry them) and hoists the block image past a leading empty paragraph', async () => {
|
||||
it('drops width/height/align (markdown  cannot carry them); the block-image hoist no longer leaves an empty paragraph', async () => {
|
||||
const { md, canonNormalized } = await runGate(imageDoc);
|
||||
|
||||
// Export is plain markdown image syntax — no dimensions/align survive.
|
||||
expect(md.trim()).toBe('');
|
||||
|
||||
// The round-tripped doc is the documented lossy shape: a leading empty
|
||||
// paragraph (block-image hoist) + an image carrying ONLY src (+ alt="").
|
||||
// The round-tripped doc carries ONLY src (+ alt=""). The leading empty
|
||||
// paragraph that the block-image hoist used to leave behind (a phantom
|
||||
// blank-gap on every sync) is now stripped on import (git-sync fix), so the
|
||||
// doc is just the image — no empty-paragraph artifact.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph' },
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { alt: '', src: 'https://example.com/pic.png' },
|
||||
@@ -420,7 +421,8 @@ describe('git-sync converter §13.1 KNOWN DIVERGENCE (markdown image lossiness)'
|
||||
],
|
||||
});
|
||||
|
||||
// And it is therefore NOT canonically equal to the original (lock the loss).
|
||||
// Still NOT canonically equal to the original: width/height/align are an
|
||||
// intrinsic markdown-transport loss (unrelated to the empty-paragraph fix).
|
||||
expect(docsCanonicallyEqual(imageDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user