import { change as amChange, getHeads, load, merge, save, type Doc } from '@automerge/automerge' import type { DocumentId } from '../../document-id.js' import type { Source } from '../source.js' /** * The application-facing adapter for a document — deliberately shaped like a * whole-doc handle so a client (an editor) gets a familiar `doc()`/`change()`/ * `on()` experience. */ export interface Handle { readonly url: DocumentId doc(): Doc change(fn: (doc: T) => void, options?: { message?: string }): void on(event: 'change', listener: () => void): void off(event: 'change', listener: () => void): void } function sameHeads(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((head, i) => head === b[i]) } /** * A live {@link Source} that is *also* the client-facing {@link Handle}. Source * and Handle have disjoint method names, so one object plays both roles: * * - The session treats it as a Source: `push` delivers the merged canonical in * (updating the client's view); `subscribe` lets the session receive the * client's local edits. * - The client treats it as a Handle: `change` applies a local edit — updating * this source's own doc and delivering it to the session — and `doc()`/`on()` * read and observe. * * Because `change` (client) and `push` (session) are distinct methods, there is * no loop and no source-tag filtering: a session push updates the view but is * never delivered back, and a client change is delivered but the session never * pushes it back (it skips the delivering source). */ export function createEditorSource(documentId: DocumentId, initialDoc: Doc): Source & Handle { // Own an independent copy — never share a doc object with the session's canonical. let doc = load(save(initialDoc)) const listeners = new Set<() => void>() let deliver: ((documentId: DocumentId, doc: Doc) => void) | undefined const notify = (): void => { for (const listener of listeners) listener() } return { // --- Source (session-facing) --- id: 'editor', async push(_documentId: DocumentId, incoming: Doc): Promise { const own = load(save(incoming)) const before = getHeads(doc) doc = merge(doc, own) if (!sameHeads(before, getHeads(doc))) notify() }, async pull(): Promise | null> { return doc as Doc }, subscribe(onInbound: (documentId: DocumentId, doc: Doc) => void): () => void { deliver = onInbound return () => { if (deliver === onInbound) deliver = undefined } }, // --- Handle (client-facing) --- url: documentId, doc(): Doc { return doc }, change(fn: (doc: T) => void, options?: { message?: string }): void { doc = options?.message ? amChange(doc, { message: options.message }, fn) : amChange(doc, fn) notify() deliver?.(documentId, doc as Doc) }, on(_event: 'change', listener: () => void): void { listeners.add(listener) }, off(_event: 'change', listener: () => void): void { listeners.delete(listener) }, } }