From 5ccccf01acdf9136bd49ded417b14cbabb7e772c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:23:25 -0400 Subject: [PATCH] fix(documents): hydrate session before RPC reads and edits The AI reads and edits collaborative documents by calling DocumentSession over native Durable Object RPC. Native RPC bypasses partyserver's onStart/onLoad, so on a hibernated session this.document was a fresh, empty Yjs doc: readMarkdownChunk returned empty content until the user opened the doc in a browser (which connects over fetch and hydrates), and applyMarkdownEdits could edit an empty document and checkpoint it over the real content. Introduce CollaborativeContentSession, a base for session DOs whose content lives in a hydrated in-memory Yjs document, exposing ensureLoaded() around the framework's initialization escape hatch. DocumentSession now awaits ensureLoaded() before any RPC method that touches the document. purgeForDeletion deliberately does not, since it only wipes storage. Future editable item types (flashcards, etc.) extend the same base so the guarantee and its rationale live in one place instead of being rediscovered per type. Co-Authored-By: Claude Opus 4.8 --- .../collaborative-content-session.ts | 31 +++++++++++++++++++ .../workspaces/documents/document-session.ts | 8 +++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/features/workspaces/collaborative-content-session.ts diff --git a/src/features/workspaces/collaborative-content-session.ts b/src/features/workspaces/collaborative-content-session.ts new file mode 100644 index 00000000..9ac4c8e4 --- /dev/null +++ b/src/features/workspaces/collaborative-content-session.ts @@ -0,0 +1,31 @@ +import { YServer } from "y-partyserver"; + +/** + * Base for collaborative session Durable Objects whose canonical content lives + * in an in-memory Yjs document that `onLoad` hydrates from durable storage. + * + * These sessions are reached two ways. Browser editors connect over + * `fetch`/WebSocket, which runs `onStart` — and therefore `onLoad` — before any + * handler touches the document. Server tools instead call methods directly over + * native Durable Object RPC, which does not run `onStart`. A content method + * invoked cold via RPC would read or edit a freshly constructed, empty document: + * returning blank content, or checkpointing an empty document over real content. + * + * Any RPC method that reads or writes the document must `await + * this.ensureLoaded()` first. Methods that only touch durable storage (deletion, + * for example) must not, so hydration stays opt-in per method rather than a + * blanket cost. New editable item types extend this base so the guarantee — and + * the reasoning behind it — live in one place. + */ +export abstract class CollaborativeContentSession extends YServer { + /** + * Runs `onStart`/`onLoad` for this instance if they have not already. Cheap + * and idempotent once started. `__unsafe_ensureInitialized` is the framework's + * sanctioned escape hatch for Durable Objects reached via native RPC; keeping + * the single call here means content methods never touch an unhydrated + * document and the rationale is not repeated at every call site. + */ + protected async ensureLoaded(): Promise { + await this.__unsafe_ensureInitialized(); + } +} diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts index 9526c0de..6286cbd5 100644 --- a/src/features/workspaces/documents/document-session.ts +++ b/src/features/workspaces/documents/document-session.ts @@ -4,9 +4,9 @@ import { yDocToProsemirrorJSON, } from "@tiptap/y-tiptap"; import type { Connection, ConnectionContext } from "partyserver"; -import { YServer } from "y-partyserver"; import * as Y from "yjs"; import type { DocumentSessionRouteParams } from "#/features/workspaces/agent-routes"; +import { CollaborativeContentSession } from "#/features/workspaces/collaborative-content-session"; import { parseMarkdownToTiptapDocumentProjection, serializeTiptapDocumentToMarkdown, @@ -62,7 +62,7 @@ export interface DocumentSessionApplyMarkdownEditsResult { warnings: string[]; } -export class DocumentSession extends YServer { +export class DocumentSession extends CollaborativeContentSession { static override options = { hibernate: true, }; @@ -131,6 +131,7 @@ export class DocumentSession extends YServer { async applyMarkdownEdits( input: DocumentSessionApplyMarkdownEditsInput, ): Promise { + await this.ensureLoaded(); const currentDocument = this.getCurrentTiptapDocument(); const markdown = serializeTiptapDocumentToMarkdown(currentDocument); const editResult = applyDocumentMarkdownEdits(markdown, input.edits); @@ -176,6 +177,7 @@ export class DocumentSession extends YServer { async readMarkdownChunk( input: DocumentMarkdownChunkReadInput, ): Promise { + await this.ensureLoaded(); const stateVector = Uint8Array.from(Y.encodeStateVector(this.document)); let currentSnapshot = this.markdownSnapshot; if (!currentSnapshot || !uint8ArraysEqual(currentSnapshot.stateVector, stateVector)) { @@ -198,6 +200,8 @@ export class DocumentSession extends YServer { } async purgeForDeletion(): Promise { + // Deliberately no ensureLoaded(): this only wipes durable storage, so + // hydrating the in-memory document first would be wasted work. await this.ctx.storage.deleteAll(); }