@@ -167,6 +167,8 @@ export class LoroWebsocketClient {
167167 private activeRooms : Map < string , ActiveRoom > = new Map ( ) ;
168168 // Buffer for %ELO only: backfills can arrive immediately after JoinResponseOk
169169 private preJoinUpdates : Map < string , Array < { updates : Uint8Array [ ] ; refId ?: HexString } > > = new Map ( ) ;
170+ // Track outbound update batches so we can surface errors with payload context
171+ private sentUpdateBatches : Map < HexString , { roomKey : string ; updates : Uint8Array [ ] } > = new Map ( ) ;
170172 private fragmentBatches : Map < string , FragmentBatch > = new Map ( ) ;
171173 private roomAdaptors : Map < string , CrdtDocAdaptor > = new Map ( ) ;
172174 // Track roomId for each active id so we can rejoin on reconnect
@@ -856,6 +858,7 @@ export class LoroWebsocketClient {
856858
857859 cleanupRoom ( roomId : string , crdtType : CrdtType ) {
858860 const id = crdtType + roomId ;
861+ this . purgeSentBatchesForRoom ( id ) ;
859862 this . activeRooms . delete ( id ) ;
860863 this . pendingRooms . delete ( id ) ;
861864 this . roomAdaptors . delete ( id ) ;
@@ -1079,6 +1082,12 @@ export class LoroWebsocketClient {
10791082 ) ;
10801083
10811084 const batchId = randomBatchId ( ) ;
1085+ const roomKey = `${ crdt } ${ roomId } ` ;
1086+ // Store the original payload so we can surface detailed errors on Ack
1087+ this . sentUpdateBatches . set ( batchId , {
1088+ roomKey,
1089+ updates : [ update . slice ( ) ] ,
1090+ } ) ;
10821091
10831092 if ( update . length <= FRAG_LIMIT ) {
10841093 // Send as a single DocUpdate with one update entry
@@ -1135,6 +1144,22 @@ export class LoroWebsocketClient {
11351144 ) ;
11361145 }
11371146
1147+ consumeSentBatch ( refId : HexString ) : { roomKey : string ; updates : Uint8Array [ ] } | undefined {
1148+ const entry = this . sentUpdateBatches . get ( refId ) ;
1149+ if ( entry ) {
1150+ this . sentUpdateBatches . delete ( refId ) ;
1151+ }
1152+ return entry ;
1153+ }
1154+
1155+ private purgeSentBatchesForRoom ( roomKey : string ) : void {
1156+ for ( const [ refId , entry ] of Array . from ( this . sentUpdateBatches . entries ( ) ) ) {
1157+ if ( entry . roomKey === roomKey ) {
1158+ this . sentUpdateBatches . delete ( refId ) ;
1159+ }
1160+ }
1161+ }
1162+
11381163 /**
11391164 * Destroy the client, removing listeners and stopping timers.
11401165 * After destroy, the instance should not be used.
@@ -1162,6 +1187,7 @@ export class LoroWebsocketClient {
11621187 this . ops . onWsClose ?.( ) ;
11631188 }
11641189 this . queuedJoins = [ ] ;
1190+ this . sentUpdateBatches . clear ( ) ;
11651191 this . detachSocketListeners ( ws ) ;
11661192 try {
11671193 this . removeNetworkListeners ?.( ) ;
@@ -1482,8 +1508,14 @@ class LoroWebsocketClientRoomImpl
14821508 }
14831509
14841510 handleAck ( ack : Ack ) {
1511+ const sent = this . client . consumeSentBatch ( ack . refId ) ;
14851512 if ( ack . status !== UpdateStatusCode . Ok ) {
1486- console . warn ( `Ack status ${ ack . status } for ${ this . crdtType } :${ this . roomId } (ref ${ ack . refId } )` ) ;
1513+ const updates = sent ?. updates ?? [ ] ;
1514+ const reason = updateStatusToReason ( ack . status ) ;
1515+ this . crdtAdaptor . onUpdateError ?.( updates , ack . status , reason ) ;
1516+ if ( ! sent ) {
1517+ console . warn ( `Ack status ${ ack . status } for ${ this . crdtType } :${ this . roomId } (ref ${ ack . refId } ) with no matching batch` ) ;
1518+ }
14871519 }
14881520 }
14891521
@@ -1524,6 +1556,27 @@ class LoroWebsocketClientRoomImpl
15241556
15251557// --- Keepalive helpers (ping/pong) ---
15261558
1559+ function updateStatusToReason ( status : UpdateStatusCode ) : string | undefined {
1560+ switch ( status ) {
1561+ case UpdateStatusCode . Unknown :
1562+ return "unknown" ;
1563+ case UpdateStatusCode . PermissionDenied :
1564+ return "permission_denied" ;
1565+ case UpdateStatusCode . InvalidUpdate :
1566+ return "invalid_update" ;
1567+ case UpdateStatusCode . PayloadTooLarge :
1568+ return "payload_too_large" ;
1569+ case UpdateStatusCode . RateLimited :
1570+ return "rate_limited" ;
1571+ case UpdateStatusCode . FragmentTimeout :
1572+ return "fragment_timeout" ;
1573+ case UpdateStatusCode . AppError :
1574+ return "app_error" ;
1575+ default :
1576+ return undefined ;
1577+ }
1578+ }
1579+
15271580// --- Internal ping helpers ---
15281581function isPositive ( v : unknown ) : v is number {
15291582 return typeof v === "number" && isFinite ( v ) && v > 0 ;
0 commit comments