Title now lives in the page's Yjs 'title' fragment, but two paths corrupted it: - Rename-revert: a REST/MCP title change wrote only the page.title column, never the Yjs fragment, so the next editor open replayed the stale Yjs title and reverted the rename. PageService.update now mirrors the new title into the Yjs 'title' fragment via CollaborationGateway.writePageTitle, which goes through openDirectConnection directly (Redis-independent: works with COLLAB_DISABLE_REDIS and in single-process deployments, unlike the Redis-routed handleYjsEvent path). The write is best-effort: a Yjs failure is logged and never rolls back the committed column write. Agent provenance (actor/aiChatId) is threaded into the store context. - Untitled-on-open: an empty/just-initialized 'title' fragment clobbered a non-empty page.title to '' on open. onStoreDocument now treats the title as changed only when the extracted text is non-empty, covering both the title-only and body+title save branches. Empty-retitling via collab is intentionally impossible; the REST DTO is the place to enforce non-empty. writeTitleFragment does a full clear+seed of the 'title' fragment (no duplication/concatenation) and leaves the body fragment intact. Removed the dead useTreeMutation.handleRename path. Adds unit tests for writeTitleFragment, the gateway write, the anti-empty-clobber guard, and agent provenance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Hocuspocus, Document } from '@hocuspocus/server';
|
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
|
import {
|
|
buildTitleSeedYdoc,
|
|
prosemirrorNodeToYElement,
|
|
tiptapExtensions,
|
|
} from './collaboration.util';
|
|
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
|
import * as Y from 'yjs';
|
|
import { User } from '@docmost/db/types/entity.types';
|
|
|
|
export type CollabEventHandlers = ReturnType<
|
|
CollaborationHandler['getHandlers']
|
|
>;
|
|
|
|
/**
|
|
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
|
|
*
|
|
* Used by the gateway's direct `writePageTitle` method to write a new page
|
|
* title INTO the page's Yjs 'title' fragment. The title lives in the same
|
|
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
|
|
* rename that only updated the page.title DB column would be reverted on the
|
|
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
|
|
* The whole fragment is replaced (no merge/append),
|
|
* mirroring the 'replace' body path: the new title fully supersedes the old.
|
|
*
|
|
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
|
|
* fragment, a REST/MCP rename arriving while a user is actively editing the
|
|
* title in an open editor WILL overwrite that in-progress edit. This is
|
|
* acceptable — the title is a short, rarely-concurrently-edited field — and is
|
|
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
|
|
* DB column to on the next save.
|
|
*/
|
|
export function writeTitleFragment(doc: Y.Doc, title: string): void {
|
|
const titleFragment = doc.getXmlFragment('title');
|
|
|
|
if (titleFragment.length > 0) {
|
|
titleFragment.delete(0, titleFragment.length);
|
|
}
|
|
|
|
const newTitleDoc = buildTitleSeedYdoc(title);
|
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
|
|
}
|
|
|
|
@Injectable()
|
|
export class CollaborationHandler {
|
|
private readonly logger = new Logger(CollaborationHandler.name);
|
|
|
|
getHandlers(hocuspocus: Hocuspocus) {
|
|
return {
|
|
alterState: async (documentName: string, payload: { pageId: string }) => {
|
|
// dummy
|
|
// this.logger.log('Processing', documentName, payload);
|
|
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
|
|
// const fragment = doc.getXmlFragment('default');
|
|
//});
|
|
},
|
|
setCommentMark: async (
|
|
documentName: string,
|
|
payload: {
|
|
yjsSelection: YjsSelection;
|
|
commentId: string;
|
|
resolved: boolean;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { yjsSelection, commentId, resolved, user } = payload;
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
setYjsMark(doc, fragment, yjsSelection, 'comment', {
|
|
commentId,
|
|
resolved,
|
|
});
|
|
},
|
|
);
|
|
},
|
|
resolveCommentMark: async (
|
|
documentName: string,
|
|
payload: {
|
|
commentId: string;
|
|
resolved: boolean;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { commentId, resolved, user } = payload;
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
updateYjsMarkAttribute(
|
|
fragment,
|
|
'comment',
|
|
{ name: 'commentId', value: commentId },
|
|
{ resolved },
|
|
);
|
|
},
|
|
);
|
|
},
|
|
updatePageContent: async (
|
|
documentName: string,
|
|
payload: {
|
|
prosemirrorJson: any;
|
|
operation: string;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { operation, user } = payload;
|
|
const { prosemirrorJson } = payload;
|
|
this.logger.debug('Updating page content via yjs', documentName);
|
|
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
|
|
if (operation === 'replace') {
|
|
if (fragment.length > 0) {
|
|
fragment.delete(0, fragment.length);
|
|
}
|
|
|
|
const newDoc = TiptapTransformer.toYdoc(
|
|
prosemirrorJson,
|
|
'default',
|
|
tiptapExtensions,
|
|
);
|
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
|
|
} else {
|
|
const newContent = prosemirrorJson.content || [];
|
|
const yElements = newContent.map(prosemirrorNodeToYElement);
|
|
const position = operation === 'prepend' ? 0 : fragment.length;
|
|
fragment.insert(position, yElements);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
async withYdocConnection(
|
|
hocuspocus: Hocuspocus,
|
|
documentName: string,
|
|
context: any = {},
|
|
fn: (doc: Document) => void,
|
|
): Promise<void> {
|
|
const connection = await hocuspocus.openDirectConnection(
|
|
documentName,
|
|
context,
|
|
);
|
|
try {
|
|
await connection.transact(fn);
|
|
} finally {
|
|
await connection.disconnect();
|
|
}
|
|
}
|
|
}
|