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 }