11import * as Automerge from '@automerge/automerge' ;
2- import { Actor , effect , type IActorClient , type StateShape } from '@d-buckner/ensemble-core' ;
2+ import { Actor , effect , type IActorClient } from '@d-buckner/ensemble-core' ;
33import type { PeerMessagingActor } from './PeerMessagingActor' ;
4- import type { CollaborationEvents , AutomergeDoc , SyncState , MessagePayload } from './types' ;
4+ import type { CollaborationEvents , AutomergeDoc , SyncState , MessagePayload , RoomJoinedPayload } from './types' ;
55import type { Draft } from 'mutative' ;
66
77/**
@@ -44,49 +44,40 @@ export interface CollaborationDeps {
4444 */
4545export class CollaborationActor < TDoc extends Record < string , any > = Record < string , any > > extends Actor < TDoc , CollaborationEvents > {
4646 // Automerge internals (private, NOT in state)
47- private automergeDoc : AutomergeDoc < TDoc > ;
47+ // Null until roomJoined - allows offline-first usage before peers connect
48+ private automergeDoc : AutomergeDoc < TDoc > | null = null ;
4849 private syncStates = new Map < string , SyncState > ( ) ;
4950
5051 protected declare deps : CollaborationDeps ;
5152
5253 /**
53- * Create a new CollaborationActor with an initial document.
54- * The document becomes the actor's state directly.
55- *
56- * @param initialDocument - Initial CRDT document (will be wrapped by Automerge)
57- */
58- constructor ( initialDocument : StateShape < TDoc > ) {
59- super ( initialDocument ) ;
60- this . automergeDoc = Automerge . from ( initialDocument as TDoc ) ;
61- }
62-
63- /**
64- * Override setState to route through Automerge CRDT.
65- *
6654 * This enables transparent conflict resolution - users call setState()
6755 * just like any other actor, but changes are automatically synced
6856 * with peers and conflicts are resolved via Automerge.
6957 *
58+ * Before peers connect (automergeDoc is null), acts as normal Actor with local state only.
59+ *
7060 * @param updater - Function that mutates a draft of the current state
61+ * @returns Promise that resolves when the state update has been committed
7162 */
72- protected setState ( updater : ( draft : Draft < TDoc > ) => void ) : void {
63+ protected setState ( updater : ( draft : Draft < TDoc > ) => void ) : Promise < void > {
64+ // If no Automerge doc yet, just update local state (offline-first)
65+ if ( ! this . automergeDoc ) {
66+ return super . setState ( updater ) ;
67+ }
68+
7369 // 1. Apply via Automerge (conflict resolution)
7470 const newDoc = Automerge . change ( this . automergeDoc , updater as any ) ;
7571 const changes = Automerge . getChanges ( this . automergeDoc , newDoc ) ;
7672 this . automergeDoc = newDoc ;
7773
7874 // 2. Update actor state via parent (triggers state events)
7975 const jsDoc = Automerge . toJS ( newDoc ) ;
80- super . setState ( draft => {
81- // Directly assign each property to ensure reactivity
82- ( Object . keys ( jsDoc ) as Array < keyof TDoc > ) . forEach ( key => {
83- ( draft as TDoc ) [ key ] = jsDoc [ key ] ;
84- } ) ;
85- } ) ;
8676
8777 // 3. Generate and send sync messages for peers
8878 if ( changes . length > 0 ) {
8979 const peers = this . deps . connection . state . connectedPeers ;
80+ console . log ( `[CollaborationActor] 📝 Broadcasting ${ changes . length } change(s) to ${ peers . length } peer(s)` ) ;
9081 for ( const peerId of peers ) {
9182 const syncMsg = this . generateSyncMessageForPeer ( peerId ) ;
9283 if ( syncMsg ) {
@@ -95,18 +86,51 @@ export class CollaborationActor<TDoc extends Record<string, any> = Record<string
9586 }
9687 }
9788 }
89+
90+ return super . setState ( draft => {
91+ // Directly assign each property to ensure reactivity
92+ ( Object . keys ( jsDoc ) as Array < keyof TDoc > ) . forEach ( key => {
93+ ( draft as TDoc ) [ key ] = jsDoc [ key ] ;
94+ } ) ;
95+ } ) ;
9896 }
9997
10098 // ========================================
10199 // Effects: React to connection events
102100 // ========================================
103101
102+ /**
103+ * Handle room joined event to initialize Automerge document.
104+ * First peer in room creates doc from current state, others start empty.
105+ */
106+ @effect ( 'connection.roomJoined' )
107+ private handleRoomJoined ( payload : RoomJoinedPayload ) : void {
108+ // Joining peer - start empty, receive via sync
109+ if ( payload . peerIds . length > 0 ) {
110+ this . automergeDoc = Automerge . init ( ) ;
111+ console . log ( `[CollaborationActor] 👋 Joining peer - initialized empty Automerge doc (${ payload . peerIds . length } existing peer(s))` ) ;
112+ return ;
113+ }
114+
115+ // First peer - initialize from current state
116+ this . automergeDoc = Automerge . from ( this . state as TDoc ) ;
117+ console . log ( '[CollaborationActor] 🎉 First peer - initialized Automerge doc from current state' ) ;
118+ }
119+
104120 /**
105121 * Handle incoming CRDT sync messages from peers.
106122 * Applies remote changes and generates response if needed.
107123 */
108124 @effect ( 'connection.messageReceived' )
109125 private handleIncomingMessage ( { peerId, message } : MessagePayload ) : void {
126+ console . log ( `[CollaborationActor] 📨 Received sync message from ${ peerId } (${ message . length } bytes)` ) ;
127+
128+ // Guard: Automerge doc must be initialized before receiving sync messages
129+ if ( ! this . automergeDoc ) {
130+ console . warn ( '[CollaborationActor] ⚠️ Received sync message before Automerge doc initialized - dropping message' ) ;
131+ return ;
132+ }
133+
110134 const syncState = this . syncStates . get ( peerId ) || Automerge . initSyncState ( ) ;
111135
112136 const [ newDoc , newSyncState ] = Automerge . receiveSyncMessage (
@@ -119,6 +143,7 @@ export class CollaborationActor<TDoc extends Record<string, any> = Record<string
119143
120144 // Document changed - update via parent setState (skip sync broadcast)
121145 if ( newDoc !== this . automergeDoc ) {
146+ console . log ( `[CollaborationActor] ✅ Document updated from ${ peerId } ` ) ;
122147 this . automergeDoc = newDoc ;
123148 const jsDoc = Automerge . toJS ( newDoc ) ;
124149 super . setState ( draft => {
@@ -127,6 +152,8 @@ export class CollaborationActor<TDoc extends Record<string, any> = Record<string
127152 ( draft as TDoc ) [ key ] = jsDoc [ key ] ;
128153 } ) ;
129154 } ) ;
155+ } else {
156+ console . log ( `[CollaborationActor] ℹ️ No document changes from ${ peerId } ` ) ;
130157 }
131158
132159 // Generate response if needed
@@ -136,9 +163,12 @@ export class CollaborationActor<TDoc extends Record<string, any> = Record<string
136163 ) ;
137164
138165 if ( responseMsg ) {
166+ console . log ( `[CollaborationActor] 📤 Sending sync response to ${ peerId } (${ responseMsg . length } bytes)` ) ;
139167 this . syncStates . set ( peerId , nextSyncState ) ;
140168 // PeerMessagingActor handles routing
141169 this . deps . connection . actions . sendTo ( peerId , responseMsg ) ;
170+ } else {
171+ console . log ( `[CollaborationActor] ℹ️ No sync response needed for ${ peerId } ` ) ;
142172 }
143173 }
144174
@@ -148,17 +178,29 @@ export class CollaborationActor<TDoc extends Record<string, any> = Record<string
148178 */
149179 @effect ( 'connection.peerConnected' )
150180 private initSyncWithPeer ( peerId : string ) : void {
181+ console . log ( `[CollaborationActor] 🔄 Peer connected, initiating sync with: ${ peerId } ` ) ;
182+
183+ // Guard: Automerge doc must be initialized before syncing with peers
184+ if ( ! this . automergeDoc ) {
185+ console . warn ( '[CollaborationActor] ⚠️ Peer connected before Automerge doc initialized - skipping sync' ) ;
186+ return ;
187+ }
188+
151189 const syncState = Automerge . initSyncState ( ) ;
152190 const [ newSyncState , message ] = Automerge . generateSyncMessage (
153191 this . automergeDoc ,
154192 syncState
155193 ) ;
156194
157- if ( message ) {
158- this . syncStates . set ( peerId , newSyncState ) ;
159- // PeerMessagingActor handles routing
160- this . deps . connection . actions . sendTo ( peerId , message ) ;
195+ if ( ! message ) {
196+ console . log ( `[CollaborationActor] ⚠️ No sync message generated for ${ peerId } ` ) ;
197+ return ;
161198 }
199+
200+ console . log ( `[CollaborationActor] 📤 Sending initial sync message to ${ peerId } (${ message . length } bytes)` ) ;
201+ this . syncStates . set ( peerId , newSyncState ) ;
202+ // PeerMessagingActor handles routing
203+ this . deps . connection . actions . sendTo ( peerId , message ) ;
162204 }
163205
164206 /**
@@ -181,6 +223,11 @@ export class CollaborationActor<TDoc extends Record<string, any> = Record<string
181223 * @returns Sync message or null if no sync needed
182224 */
183225 private generateSyncMessageForPeer ( peerId : string ) : Uint8Array | null {
226+ // Guard: Automerge doc must be initialized
227+ if ( ! this . automergeDoc ) {
228+ return null ;
229+ }
230+
184231 const syncState = this . syncStates . get ( peerId ) || Automerge . initSyncState ( ) ;
185232 const [ newSyncState , message ] = Automerge . generateSyncMessage (
186233 this . automergeDoc ,
0 commit comments