From 9685074237b71de3f15af6e897f055b745229ab8 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 02:53:23 +0300 Subject: [PATCH] =?UTF-8?q?perf(collab):=20=D1=82=D1=80=D0=B8=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B0=20=D0=B3=D0=BE=D1=80=D1=8F=D1=87=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D1=83=D1=82=D0=B8=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B0=20=E2=80=94=20connect-vs-unload=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0,=20=D0=B4=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0,=20isDeepStrictEqual=20(?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Побочные находки инцидента #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) --- .../extensions/hocuspocus-unload-race.spec.ts | 140 ++++++++++++++++++ .../extensions/persistence-load.spec.ts | 130 ++++++++++++++++ .../extensions/persistence.extension.ts | 23 ++- package.json | 3 +- patches/@hocuspocus__server@3.4.4.patch | 62 ++++++++ pnpm-lock.yaml | 7 +- 6 files changed, 356 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/collaboration/extensions/hocuspocus-unload-race.spec.ts create mode 100644 apps/server/src/collaboration/extensions/persistence-load.spec.ts create mode 100644 patches/@hocuspocus__server@3.4.4.patch diff --git a/apps/server/src/collaboration/extensions/hocuspocus-unload-race.spec.ts b/apps/server/src/collaboration/extensions/hocuspocus-unload-race.spec.ts new file mode 100644 index 00000000..ed357d15 --- /dev/null +++ b/apps/server/src/collaboration/extensions/hocuspocus-unload-race.spec.ts @@ -0,0 +1,140 @@ +/** + * gitmost #401 — regression test for the connect-vs-unload race in + * @hocuspocus/server 3.4.4 (patched via patches/@hocuspocus__server@3.4.4.patch). + * + * The race (unpatched): when the last client disconnects, storeDocumentHooks' + * `finally` schedules an async `unloadDocument`. That unload runs its + * `beforeUnloadDocument` hooks asynchronously and, meanwhile, records an + * in-flight promise in `this.unloadingDocuments`. In the original 3.4.4 + * `createDocument`, a NEW connection arriving in that window falls straight + * through to the `loadingDocuments`/`documents` checks — it never consults + * `unloadingDocuments`. So the new connection can start loading (or reuse) a + * document while the old instance is still being torn down; the re-check inside + * unload (`shouldUnloadDocument`, which sees 0 connections because async auth + * hooks have not registered the new connection yet) then deletes/destroys the + * doc out from under the freshly-connected client → orphaned Document → later + * redis-sync takes the "doc not loaded" path → sync never completes → the + * provider hangs until its ~25s timeout. + * + * The patch: `createDocument` first awaits any in-flight + * `unloadingDocuments.get(name)` before proceeding. Once that settles, the + * decision is deterministic — either the doc was fully unloaded (gone from + * `documents`, so a clean fresh load) or the unload aborted (healthy doc still + * in `documents`, reused). The new connection can never hand-shake onto an + * about-to-be-destroyed Document. + * + * These tests exercise the REAL patched `Hocuspocus.createDocument` (the class + * is directly constructible) by seeding `unloadingDocuments` with a controllable + * in-flight unload and observing that createDocument waits for it. + */ +import { Hocuspocus } from '@hocuspocus/server'; + +// A promise we can resolve on demand, to model an unload that is mid-flight. +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe('gitmost #401 — hocuspocus createDocument awaits in-flight unload', () => { + it('does NOT start loading a new doc until the in-flight unload settles, then loads fresh', async () => { + const hp = new Hocuspocus(); + const name = 'page.race'; + + // Observe loadDocument: on the unpatched code it is invoked synchronously + // within createDocument (before the unload settles); on the patched code it + // must be deferred until unloadingDocuments resolves. + const freshDoc = { name, __fresh: true } as any; + const loadSpy = jest + .spyOn(hp as any, 'loadDocument') + .mockResolvedValue(freshDoc); + + // Model an unload in progress: an entry sits in unloadingDocuments and, when + // it completes, it removes the doc from `documents` (a real full unload). + const unload = deferred(); + (hp as any).documents.set(name, { name, __dying: true }); + (hp as any).unloadingDocuments.set( + name, + unload.promise.then(() => { + (hp as any).documents.delete(name); + }), + ); + + // Kick off a new connection's createDocument but do not await it yet. + const createPromise = (hp as any).createDocument( + name, + {}, + 'socket-1', + { isAuthenticated: true, readOnly: false }, + {}, + ); + + // Let all currently-schedulable microtasks run. The patched createDocument is + // now parked on `await unloadingDocuments.get(name)`, so loadDocument must + // NOT have been called yet, and it must NOT have returned the dying doc. + await Promise.resolve(); + await Promise.resolve(); + expect(loadSpy).not.toHaveBeenCalled(); + + // The unload completes (doc removed from `documents`). + unload.resolve(); + + // createDocument now proceeds: sees no existing doc → fresh load. + const doc = await createPromise; + expect(loadSpy).toHaveBeenCalledTimes(1); + expect(doc).toBe(freshDoc); + // The freshly-loaded doc is the one registered — never the dying instance. + expect((hp as any).documents.get(name)).toBe(freshDoc); + }); + + it('reuses the live doc when the in-flight unload aborts (doc left in documents)', async () => { + const hp = new Hocuspocus(); + const name = 'page.abort'; + + const loadSpy = jest.spyOn(hp as any, 'loadDocument'); + + // Model an unload that ABORTS (e.g. a new connection reappeared before the + // sync re-check): it settles WITHOUT deleting the doc from `documents`. + const unload = deferred(); + const liveDoc = { name, __live: true } as any; + (hp as any).documents.set(name, liveDoc); + (hp as any).unloadingDocuments.set(name, unload.promise); // no-op unload + + const createPromise = (hp as any).createDocument( + name, + {}, + 'socket-2', + { isAuthenticated: true, readOnly: false }, + {}, + ); + + unload.resolve(); + const doc = await createPromise; + + // The still-present live doc is reused; no fresh load happened. + expect(doc).toBe(liveDoc); + expect(loadSpy).not.toHaveBeenCalled(); + }); + + it('no in-flight unload → behaves normally (fresh load)', async () => { + const hp = new Hocuspocus(); + const name = 'page.normal'; + const freshDoc = { name } as any; + const loadSpy = jest + .spyOn(hp as any, 'loadDocument') + .mockResolvedValue(freshDoc); + + const doc = await (hp as any).createDocument( + name, + {}, + 'socket-3', + { isAuthenticated: true, readOnly: false }, + {}, + ); + + expect(loadSpy).toHaveBeenCalledTimes(1); + expect(doc).toBe(freshDoc); + }); +}); diff --git a/apps/server/src/collaboration/extensions/persistence-load.spec.ts b/apps/server/src/collaboration/extensions/persistence-load.spec.ts new file mode 100644 index 00000000..af92a9dd --- /dev/null +++ b/apps/server/src/collaboration/extensions/persistence-load.spec.ts @@ -0,0 +1,130 @@ +/** + * 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); + }); +}); diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 10afc82e..520fa9de 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -171,15 +171,21 @@ export class PersistenceExtension implements Extension { return; } + // #401 fix 2 — apply the DB state DIRECTLY into the hook's target document + // (`document` === `data.document`) and return undefined. When onLoadDocument + // returns undefined, hocuspocus keeps the mutated hook document as-is; only + // when the hook RETURNS a Y.Doc does hocuspocus re-`applyUpdate(document, + // encodeStateAsUpdate(returned))` — a second full encode+apply of the whole + // (e.g. 315KB) state on every cold load. Mutating in place performs a single + // apply and avoids the throwaway `new Y.Doc()` allocation. if (page.ydoc) { this.logger.debug(`ydoc loaded from db: ${pageId}`); - const doc = new Y.Doc(); const dbState = new Uint8Array(page.ydoc); - Y.applyUpdate(doc, dbState); + Y.applyUpdate(document, dbState); observeCollabLoad(dbState.length, (performance.now() - startedAt) / 1000); - return doc; + return; } // if no ydoc state in db convert json in page.content to Ydoc. @@ -192,18 +198,23 @@ export class PersistenceExtension implements Extension { tiptapExtensions, ); - // Reuse this single encode for the size label (do NOT add a second one). + // Encode the converted doc ONCE, reuse the bytes for both the size label + // and the single apply into the hook document (previously this encode's + // result was returned and hocuspocus re-encoded+applied it a second time). const encoded = Y.encodeStateAsUpdate(ydoc); + Y.applyUpdate(document, encoded); observeCollabLoad( encoded.byteLength, (performance.now() - startedAt) / 1000, ); - return ydoc; + return; } + // No persisted state: the hook document is already a fresh empty Y.Doc, so + // leave it untouched and return undefined (no re-encode of an empty doc). this.logger.debug(`creating fresh ydoc: ${pageId}`); observeCollabLoad(0, (performance.now() - startedAt) / 1000); - return new Y.Doc(); + return; } async onStoreDocument(data: onStoreDocumentPayload) { diff --git a/package.json b/package.json index 1e295a96..994adae7 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,8 @@ "patchedDependencies": { "scimmy@1.3.5": "patches/scimmy@1.3.5.patch", "yjs@13.6.30": "patches/yjs@13.6.30.patch", - "ai@6.0.134": "patches/ai@6.0.134.patch" + "ai@6.0.134": "patches/ai@6.0.134.patch", + "@hocuspocus/server@3.4.4": "patches/@hocuspocus__server@3.4.4.patch" }, "overrides": { "prosemirror-changeset": "2.4.0", diff --git a/patches/@hocuspocus__server@3.4.4.patch b/patches/@hocuspocus__server@3.4.4.patch new file mode 100644 index 00000000..b5c79cb8 --- /dev/null +++ b/patches/@hocuspocus__server@3.4.4.patch @@ -0,0 +1,62 @@ +diff --git a/dist/hocuspocus-server.cjs b/dist/hocuspocus-server.cjs +index b24ff6d091c32f733089eeaa47b03f7b37cf5964..f003af304fc751b7edc1aee17f3651282d70666a 100644 +--- a/dist/hocuspocus-server.cjs ++++ b/dist/hocuspocus-server.cjs +@@ -2426,6 +2426,26 @@ class Hocuspocus { + * Create a new document by the given request + */ + async createDocument(documentName, request, socketId, connection, context) { ++ // PATCH(gitmost #401): close the connect-vs-unload race. When the last ++ // client disconnects, storeDocumentHooks' finally schedules an unload; ++ // unloadDocument runs its beforeUnloadDocument hooks asynchronously and ++ // records an in-flight promise in `unloadingDocuments`. A NEW connection ++ // arriving in that window would otherwise fall straight through to the ++ // `documents`/`loadingDocuments` checks and could either reuse a doc that ++ // is about to be destroyed, or start loading a fresh doc concurrently ++ // with the destroy. Awaiting the in-flight unload first makes the decision ++ // deterministic: once it settles, either the doc was fully unloaded ++ // (removed from `documents`, so we do a clean fresh load below) or the ++ // unload aborted because work/connections reappeared (the healthy doc is ++ // still in `documents`, so we reuse it). Either way the new connection can ++ // never hand-shake onto an orphaned, about-to-be-destroyed Document. ++ const existingUnloadingDoc = this.unloadingDocuments.get(documentName); ++ if (existingUnloadingDoc) { ++ // Wait for the in-flight unload to settle so we never hand-shake onto a dying ++ // Document. Swallow a rejected unload — fall through to a fresh load, matching ++ // pre-patch behavior (the doc is already removed from `documents` by then). ++ try { await existingUnloadingDoc; } catch { /* unload rejected — fresh load */ } ++ } + const existingLoadingDoc = this.loadingDocuments.get(documentName); + if (existingLoadingDoc) { + return existingLoadingDoc; +diff --git a/dist/hocuspocus-server.esm.js b/dist/hocuspocus-server.esm.js +index 1f4dd80244e899128e2c4e5dad8eab7cfc1cbad6..8c2411747bba27fb9486e1df81678a14e41e884e 100644 +--- a/dist/hocuspocus-server.esm.js ++++ b/dist/hocuspocus-server.esm.js +@@ -2406,6 +2406,26 @@ class Hocuspocus { + * Create a new document by the given request + */ + async createDocument(documentName, request, socketId, connection, context) { ++ // PATCH(gitmost #401): close the connect-vs-unload race. When the last ++ // client disconnects, storeDocumentHooks' finally schedules an unload; ++ // unloadDocument runs its beforeUnloadDocument hooks asynchronously and ++ // records an in-flight promise in `unloadingDocuments`. A NEW connection ++ // arriving in that window would otherwise fall straight through to the ++ // `documents`/`loadingDocuments` checks and could either reuse a doc that ++ // is about to be destroyed, or start loading a fresh doc concurrently ++ // with the destroy. Awaiting the in-flight unload first makes the decision ++ // deterministic: once it settles, either the doc was fully unloaded ++ // (removed from `documents`, so we do a clean fresh load below) or the ++ // unload aborted because work/connections reappeared (the healthy doc is ++ // still in `documents`, so we reuse it). Either way the new connection can ++ // never hand-shake onto an orphaned, about-to-be-destroyed Document. ++ const existingUnloadingDoc = this.unloadingDocuments.get(documentName); ++ if (existingUnloadingDoc) { ++ // Wait for the in-flight unload to settle so we never hand-shake onto a dying ++ // Document. Swallow a rejected unload — fall through to a fresh load, matching ++ // pre-patch behavior (the doc is already removed from `documents` by then). ++ try { await existingUnloadingDoc; } catch { /* unload rejected — fresh load */ } ++ } + const existingLoadingDoc = this.loadingDocuments.get(documentName); + if (existingLoadingDoc) { + return existingLoadingDoc; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1a7795e..71417d37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ overrides: ip-address: 10.1.1 patchedDependencies: + '@hocuspocus/server@3.4.4': + hash: d8dc66e5ec3b9d23a876b979f493b6aa901fd2d965be54729495da5136296a42 + path: patches/@hocuspocus__server@3.4.4.patch ai@6.0.134: hash: f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9 path: patches/ai@6.0.134.patch @@ -75,7 +78,7 @@ importers: version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) '@hocuspocus/server': specifier: 3.4.4 - version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) + version: 3.4.4(patch_hash=d8dc66e5ec3b9d23a876b979f493b6aa901fd2d965be54729495da5136296a42)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) '@hocuspocus/transformer': specifier: 3.4.4 version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) @@ -13055,7 +13058,7 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/server@3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))': + '@hocuspocus/server@3.4.4(patch_hash=d8dc66e5ec3b9d23a876b979f493b6aa901fd2d965be54729495da5136296a42)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))': dependencies: '@hocuspocus/common': 3.4.4 async-lock: 1.4.1 -- 2.52.0