Files
gitmost/apps/server/src/collaboration/collaboration.handler.spec.ts
claude code agent 227 e8805b39c8 fix(collab): persist renamed title fragment to page.ydoc (F1, variant C)
A REST/MCP/agent rename (no live editor) wrote the new title to the
page.title column, then writePageTitle loaded the ydoc (fragment still
OLD) and set it to NEW only in memory. On disconnect onStoreDocument saw
titleText(NEW) === column(NEW), took the no-op fast-path, and never
persisted the in-memory fragment — so page.ydoc kept the OLD title and a
later body edit silently reverted the column back to OLD.

writePageTitle now persists the 'title' fragment to page.ydoc DIRECTLY
(PersistenceExtension.persistTitleFragmentYdoc) after the transact,
bypassing the no-op onStoreDocument. The write carries no treeUpdate, so
the tree WS/redis listeners do not re-broadcast (no double broadcast),
and it is idempotent/lock-free so it is safe whether or not a live editor
is connected. Adds a persist-then-reload-then-edit-body regression test
that fails on the old no-op behaviour and passes after the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:40:34 +03:00

156 lines
5.6 KiB
TypeScript

import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { writeTitleFragment } from './collaboration.handler';
import { CollaborationGateway } from './collaboration.gateway';
import {
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
// Read the plain text held in the doc's 'title' XmlFragment, the same way
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
const readTitleText = (doc: Y.Doc): string => {
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
return titleJson ? jsonToText(titleJson).trim() : '';
};
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
// Seed the doc's 'title' fragment with an OLD title, like a real page.
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
expect(readTitleText(doc)).toBe('Old Title');
writeTitleFragment(doc, 'New Title');
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
// (append) or "New TitleNew Title" (duplication). A single heading node.
expect(readTitleText(doc)).toBe('New Title');
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
expect(titleJson.content).toHaveLength(1);
expect(titleJson.content[0].type).toBe('heading');
});
it('seeds the title fragment when it started empty', () => {
const doc = new Y.Doc();
// Force the 'title' fragment to exist but be empty.
doc.getXmlFragment('title');
expect(readTitleText(doc)).toBe('');
writeTitleFragment(doc, 'First Title');
expect(readTitleText(doc)).toBe('First Title');
});
it('does not corrupt the body when rewriting the title', () => {
// A doc with both a body and an old title; the body must survive untouched.
const doc = new Y.Doc();
const bodyDoc = TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
],
},
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
writeTitleFragment(doc, 'New');
expect(readTitleText(doc)).toBe('New');
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
expect(jsonToText(bodyJson)).toContain('body text');
});
});
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
// method must drive the clear+seed through that direct connection (NOT through
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
const makeGateway = (doc: Y.Doc) => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
fn(doc);
});
const openDirectConnection = jest
.fn()
.mockResolvedValue({ transact, disconnect });
const gateway = Object.create(CollaborationGateway.prototype);
// redisSync is intentionally null — this is the no-Redis scenario.
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
// later body edit can't revert the rename (see title-rename-durability.spec).
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
return {
gateway,
openDirectConnection,
transact,
disconnect,
persistTitleFragmentYdoc,
};
};
it('writes the new title via openDirectConnection and disconnects', async () => {
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
const { gateway, openDirectConnection, disconnect, persistTitleFragmentYdoc } =
makeGateway(doc);
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ user: { id: 'u1' } }),
);
expect(readTitleText(doc)).toBe('New Title');
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
'page-1',
expect.any(Buffer),
);
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('threads agent provenance into the connection context', async () => {
const doc = new Y.Doc();
const { gateway, openDirectConnection } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'Agent Title', {
user: { id: 'u1' },
actor: 'agent',
aiChatId: 'chat-1',
});
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
);
});
it('disconnects even when the transaction throws', async () => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const openDirectConnection = jest.fn().mockResolvedValue({
transact: jest.fn().mockRejectedValue(new Error('boom')),
disconnect,
});
const gateway = Object.create(CollaborationGateway.prototype);
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
await expect(
gateway.writePageTitle('page-1', 'X', {}),
).rejects.toThrow('boom');
expect(disconnect).toHaveBeenCalledTimes(1);
});
});