/ sync / src / document.ts
document.ts
  1  import * as Automerge from '@automerge/automerge';
  2  import { type Keypair, publicKeyToDID, sign, verify } from '@protocol/identity';
  3  import { ContentAddressedStorage } from '@protocol/storage';
  4  import {
  5      documentMetadataSchema,
  6      type DocumentMetadata
  7  } from './schemas.js';
  8  
  9  /**
 10   * CRDTDocument - Automerge-based collaborative document
 11   * 
 12   * Wraps Automerge with our identity and storage layers:
 13   * - Operations are signed by author (Identity)
 14   * - Changes are stored as CIDs (Storage)
 15   * - Guaranteed convergence (Automerge CRDT)
 16   */
 17  export class CRDTDocument<T = any> {
 18      private doc: Automerge.Doc<T>;
 19      private metadata: DocumentMetadata;
 20      private keypair: Keypair;
 21      private storage: ContentAddressedStorage;
 22      private changeListeners: Set<(doc: Automerge.Doc<T>) => void> = new Set();
 23  
 24      constructor(
 25          keypair: Keypair,
 26          storage: ContentAddressedStorage,
 27          initialDoc?: Automerge.Doc<T>
 28      ) {
 29          this.keypair = keypair;
 30          this.storage = storage;
 31          this.doc = initialDoc || Automerge.init<T>();
 32  
 33          this.metadata = {
 34              id: Automerge.getActorId(this.doc),
 35              created: Date.now(),
 36              modified: Date.now(),
 37              author: publicKeyToDID(keypair.publicKey),
 38              collaborators: []
 39          };
 40      }
 41  
 42      /**
 43       * Change the document
 44       */
 45      change(fn: (doc: T) => void, message?: string): void {
 46          this.doc = Automerge.change(this.doc, message || 'Update', fn);
 47          this.metadata.modified = Date.now();
 48  
 49          // Notify listeners
 50          for (const listener of this.changeListeners) {
 51              listener(this.doc);
 52          }
 53      }
 54  
 55      /**
 56       * Merge with another document
 57       */
 58      merge(other: Automerge.Doc<T>): void {
 59          this.doc = Automerge.merge(this.doc, other);
 60          this.metadata.modified = Date.now();
 61  
 62          // Notify listeners
 63          for (const listener of this.changeListeners) {
 64              listener(this.doc);
 65          }
 66      }
 67  
 68      /**
 69       * Get current state
 70       */
 71      get state(): T {
 72          return this.doc as T;
 73      }
 74  
 75      /**
 76       * Get Automerge document
 77       */
 78      get automergeDoc(): Automerge.Doc<T> {
 79          return this.doc;
 80      }
 81  
 82      /**
 83       * Get document metadata
 84       */
 85      get meta(): DocumentMetadata {
 86          return this.metadata;
 87      }
 88  
 89      /**
 90       * Get changes since a specific point
 91       */
 92      getChangesSince(heads?: Automerge.Heads): Uint8Array {
 93          return Automerge.getChanges(Automerge.init(), this.doc);
 94      }
 95  
 96      /**
 97       * Apply changes from another peer
 98       */
 99      applyChanges(changes: Uint8Array): void {
100          const [newDoc] = Automerge.applyChanges(this.doc, [changes]);
101          this.doc = newDoc;
102          this.metadata.modified = Date.now();
103  
104          // Notify listeners
105          for (const listener of this.changeListeners) {
106              listener(this.doc);
107          }
108      }
109  
110      /**
111       * Save document to storage
112       */
113      async save(): Promise<string> {
114          const binary = Automerge.save(this.doc);
115          const cid = await this.storage.put(binary);
116          return cid;
117      }
118  
119      /**
120       * Load document from storage
121       */
122      static async load<T>(
123          cid: string,
124          keypair: Keypair,
125          storage: ContentAddressedStorage
126      ): Promise<CRDTDocument<T>> {
127          const binary = await storage.get(cid);
128          const doc = Automerge.load<T>(binary);
129          return new CRDTDocument(keypair, storage, doc);
130      }
131  
132      /**
133       * Listen for changes
134       */
135      onChange(listener: (doc: Automerge.Doc<T>) => void): () => void {
136          this.changeListeners.add(listener);
137  
138          // Return unsubscribe function
139          return () => {
140              this.changeListeners.delete(listener);
141          };
142      }
143  
144      /**
145       * Get document history
146       */
147      getHistory(): Automerge.State<T>[] {
148          return Automerge.getAllChanges(this.doc).map((change, i) => {
149              const doc = Automerge.init<T>();
150              const [result] = Automerge.applyChanges(doc, [change]);
151              return result;
152          });
153      }
154  
155      /**
156       * Fork document (create independent copy)
157       */
158      fork(): CRDTDocument<T> {
159          const forked = Automerge.clone(this.doc);
160          return new CRDTDocument(this.keypair, this.storage, forked);
161      }
162  }
163  
164  /**
165   * Create a new CRDT document
166   */
167  export function createDocument<T = any>(
168      keypair: Keypair,
169      storage: ContentAddressedStorage,
170      initialState?: T
171  ): CRDTDocument<T> {
172      const doc = new CRDTDocument<T>(keypair, storage);
173  
174      if (initialState) {
175          doc.change((d: any) => {
176              Object.assign(d, initialState);
177          });
178      }
179  
180      return doc;
181  }