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(); }