9685074237
Побочные находки инцидента #400 (правка большой таблицы через MCP подвешивает всё). 1. Гонка connect-vs-unload в @hocuspocus/server 3.4.4 (вероятный источник 25s connect-таймаутов): createDocument проверяет loadingDocuments/documents, но НЕ ждёт unloadingDocuments -> новое соединение может захендшейкаться на умирающий Document -> redis-sync идёт по пути 'doc не загружен', провайдер висит до таймаута. Апстрим (main) не починен. pnpm-патч (инфра как у yjs-патча): в начале createDocument await in-flight unload (обёрнут в try/catch — отклонённый unload не отравляет открытие, поведение как до патча), в ОБОИХ рантаймах (cjs+esm). Тест hocuspocus-unload-race: реальный createDocument с засеянным in-flight unload -> не грузит пока unload не осел; при откате патча тест краснеет. 2. Двойная перекодировка в onLoadDocument (persistence.extension.ts): хук строил НОВЫЙ Y.Doc и возвращал его -> hocuspocus делал applyUpdate(encodeStateAsUpdate) ВТОРОЙ раз (315КБ на каждую холодную загрузку); в JSON-ветке результат encode выбрасывался (мёртвый вызов). Теперь стейт применяется прямо в data.document, возврат undefined (hocuspocus мержит только при возврате Doc); мёртвый encode убран. Содержимое документа не меняется — только меньше encode/alloc. 3. isDeepStrictEqual по 84КБ JSON на каждом store: замерил — 1.32мс на 90КБ (immaterial, <50мс порога; доминируют fromYdoc+encodeStateAsUpdate). Изменений кода НЕТ по правилу задачи (dirty-флаг только при material). closes #401 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
/**
|
|
* gitmost #401 fix 2 — onLoadDocument applies the DB state directly into the
|
|
* hook's target document and returns undefined (instead of building a NEW Y.Doc
|
|
* and returning it, which made hocuspocus re-encode+apply the whole state a
|
|
* SECOND time on every cold load).
|
|
*
|
|
* These tests assert:
|
|
* - the hook mutates `data.document` in place so its content equals the DB doc,
|
|
* - onLoadDocument returns undefined (so hocuspocus keeps the mutated doc and
|
|
* does NOT run its own applyUpdate(encodeStateAsUpdate(...)) merge),
|
|
* - both the raw-ydoc branch and the json→ydoc conversion branch behave so.
|
|
*
|
|
* Returning undefined is the observable signal that the double-encode is gone
|
|
* (the old code returned a new Y.Doc, which made hocuspocus re-encode+apply the
|
|
* state a second time); we assert that contract rather than counting internal
|
|
* encode calls, which is brittle given the encodes inside toYdoc and the test's
|
|
* own `expected` fixtures.
|
|
*/
|
|
import * as Y from 'yjs';
|
|
import { Document } from '@hocuspocus/server';
|
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
|
import { PersistenceExtension } from './persistence.extension';
|
|
import { tiptapExtensions } from '../collaboration.util';
|
|
|
|
// A fresh hocuspocus Document (extends Y.Doc, adds isEmpty()) as hocuspocus
|
|
// hands to onLoadDocument on a cold load.
|
|
const freshDoc = () => new Document(`page.${PAGE_ID}`, {});
|
|
|
|
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
|
|
|
const doc = (text: string) => ({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
});
|
|
|
|
const jsonOf = (ydoc: Y.Doc) =>
|
|
TiptapTransformer.fromYdoc(ydoc, 'default');
|
|
|
|
describe('PersistenceExtension.onLoadDocument — #401 fix 2 (apply-into-hook-doc)', () => {
|
|
let ext: PersistenceExtension;
|
|
let pageRepo: { findById: jest.Mock };
|
|
|
|
beforeEach(() => {
|
|
pageRepo = { findById: jest.fn() };
|
|
ext = new PersistenceExtension(
|
|
pageRepo as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
);
|
|
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
|
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
|
});
|
|
|
|
const load = (document: Document) =>
|
|
ext.onLoadDocument({ documentName: `page.${PAGE_ID}`, document } as any);
|
|
|
|
it('raw ydoc branch: mutates the hook doc to the DB state and returns undefined', async () => {
|
|
// Source doc representing the persisted ydoc state.
|
|
const source = TiptapTransformer.toYdoc(
|
|
doc('DB CONTENT'),
|
|
'default',
|
|
tiptapExtensions,
|
|
);
|
|
const dbState = Buffer.from(Y.encodeStateAsUpdate(source));
|
|
pageRepo.findById.mockResolvedValue({ id: PAGE_ID, ydoc: dbState });
|
|
|
|
// The hook target is a fresh empty doc (as hocuspocus supplies on cold load).
|
|
const target = freshDoc();
|
|
const result = await load(target);
|
|
|
|
// Return undefined so hocuspocus keeps `target` as-is (no second merge).
|
|
expect(result).toBeUndefined();
|
|
// The hook document now carries the DB content.
|
|
expect(jsonOf(target)).toEqual(jsonOf(source));
|
|
});
|
|
|
|
it('json→ydoc branch: converts page.content into the hook doc and returns undefined', async () => {
|
|
pageRepo.findById.mockResolvedValue({
|
|
id: PAGE_ID,
|
|
ydoc: null,
|
|
content: doc('JSON CONTENT'),
|
|
});
|
|
|
|
const target = freshDoc();
|
|
const result = await load(target);
|
|
|
|
// Returning undefined is what keeps hocuspocus from re-encoding+applying the
|
|
// state a second time (the old code returned the doc, forcing that extra
|
|
// encode). We assert the observable contract here — the return value and the
|
|
// resulting content — rather than counting internal encode calls, which is
|
|
// brittle: toYdoc and the `expected` build below both encode too.
|
|
expect(result).toBeUndefined();
|
|
|
|
// The converted content landed in the hook document.
|
|
const expected = TiptapTransformer.toYdoc(
|
|
doc('JSON CONTENT'),
|
|
'default',
|
|
tiptapExtensions,
|
|
);
|
|
expect(jsonOf(target)).toEqual(jsonOf(expected));
|
|
});
|
|
|
|
it('live doc already non-empty: early return, no DB read', async () => {
|
|
// A hocuspocus Document carrying live content (isEmpty('default') === false).
|
|
const target = freshDoc();
|
|
const live = TiptapTransformer.toYdoc(
|
|
doc('LIVE'),
|
|
'default',
|
|
tiptapExtensions,
|
|
);
|
|
Y.applyUpdate(target, Y.encodeStateAsUpdate(live));
|
|
|
|
const result = await load(target);
|
|
expect(result).toBeUndefined();
|
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('no persisted state: leaves the fresh empty doc untouched, returns undefined', async () => {
|
|
pageRepo.findById.mockResolvedValue({ id: PAGE_ID, ydoc: null, content: null });
|
|
const target = freshDoc();
|
|
const result = await load(target);
|
|
expect(result).toBeUndefined();
|
|
expect(target.isEmpty('default')).toBe(true);
|
|
});
|
|
});
|