@@ -134,6 +134,7 @@ class ChatFileModel(BaseModel):
134134class ChatForm (BaseModel ):
135135 chat : dict
136136 folder_id : str | None = None
137+ deleted_message_ids : list [str ] | None = None
137138
138139
139140class ChatImportForm (ChatForm ):
@@ -497,6 +498,70 @@ def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
497498 if msg .get ('parentId' ) and msg ['parentId' ] not in messages_map
498499 }
499500
501+ @staticmethod
502+ def _expand_deleted_ids (messages_map : dict , deleted_ids , protected_ids = ()) -> set [str ]:
503+ """Grow an explicit deletion set to its orphaned descendants, keeping any
504+ node that reappears in ``protected_ids`` (reparented children survive).
505+ """
506+ dead = set (deleted_ids )
507+ if not dead :
508+ return dead
509+ changed = True
510+ while changed :
511+ changed = False
512+ for message_id , message in messages_map .items ():
513+ if message_id in dead or message_id in protected_ids :
514+ continue
515+ if message .get ('parentId' ) in dead :
516+ dead .add (message_id )
517+ changed = True
518+ # Intersect so unknown/already-gone ids in deleted_ids don't leak out.
519+ return dead & set (messages_map )
520+
521+ @staticmethod
522+ def merge_history (existing_history : dict , incoming_history : dict , deleted_message_ids = None ) -> dict :
523+ """Merge an incoming history onto the stored one without inferring deletions.
524+
525+ Messages absent from the push are kept — only ``deleted_message_ids`` and
526+ their orphaned descendants are removed — so a stale client can't drop a
527+ message it never saw. Blob counterpart to ``reconcile_messages_by_chat_id``.
528+ """
529+ existing = (existing_history or {}).get ('messages' ) or {}
530+ incoming = (incoming_history or {}).get ('messages' ) or {}
531+ merged = {** existing , ** incoming }
532+ # Drop non-dict nodes so a corrupt stored entry can't crash the rebuild below.
533+ merged = {mid : msg for mid , msg in merged .items () if isinstance (msg , dict )}
534+
535+ for message_id in ChatTable ._expand_deleted_ids (
536+ merged , deleted_message_ids or [], protected_ids = incoming
537+ ):
538+ merged .pop (message_id , None )
539+
540+ # Rebuild childrenIds from parentId so concurrent branches stay reachable
541+ # (display only; the model context walks parentId, not childrenIds).
542+ for message in merged .values ():
543+ message ['childrenIds' ] = [
544+ child_id for child_id in (message .get ('childrenIds' ) or []) if child_id in merged
545+ ]
546+ for message_id , message in merged .items ():
547+ parent_id = message .get ('parentId' )
548+ if parent_id in merged and message_id not in merged [parent_id ]['childrenIds' ]:
549+ merged [parent_id ]['childrenIds' ].append (message_id )
550+
551+ # Keep the push's currentId; fall back only if it was pruned.
552+ current_id = (incoming_history or {}).get ('currentId' )
553+ if current_id not in merged :
554+ fallback = (existing_history or {}).get ('currentId' )
555+ current_id = fallback if fallback in merged else None
556+
557+ # Preserve any other history keys from the push (fall back to existing),
558+ # then override messages/currentId with the merged result.
559+ return {
560+ ** (incoming_history or existing_history or {}),
561+ 'messages' : merged ,
562+ 'currentId' : current_id ,
563+ }
564+
500565 async def backfill_messages_by_chat_id (self , chat_id : str , user_id : str , messages : dict [str , dict ]) -> None :
501566 """Write messages to the ``chat_message`` table so future lookups
502567 use the fast path. Errors are logged but never raised.
@@ -514,21 +579,29 @@ async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, message
514579 except Exception as e :
515580 log .warning ('Backfill failed for message %s in chat %s: %s' , message_id , chat_id , e )
516581
517- async def reconcile_messages_by_chat_id (self , chat_id : str , user_id : str , messages : dict [str , dict ]) -> None :
582+ async def reconcile_messages_by_chat_id (
583+ self , chat_id : str , user_id : str , messages : dict [str , dict ], deleted_message_ids = None
584+ ) -> None :
518585 """Sync ``chat_message`` rows with the committed JSON blob.
519586
520- Upserts current messages via ``backfill_messages_by_chat_id``
521- and deletes orphaned rows whose message_id no longer appears
522- in the blob. Best-effort: errors are logged but never raised.
587+ Upserts the pushed messages via ``backfill_messages_by_chat_id``, then
588+ prunes only the IDs the client explicitly reported in ``deleted_message_ids``
589+ plus their orphaned descendants. Rows merely missing from the push are never
590+ inferred as deletions — otherwise a stale/lost-update save would silently drop
591+ a still-valid message from the model context. Best-effort: errors are logged
592+ but never raised.
523593 """
524594 try :
525595 await self .backfill_messages_by_chat_id (chat_id , user_id , messages )
526596
527- existing_map = await ChatMessages .get_messages_map_by_chat_id (chat_id )
528- if existing_map is not None :
529- orphaned_ids = set (existing_map .keys ()) - set (messages .keys ())
530- if orphaned_ids :
531- await ChatMessages .delete_message_ids_by_chat_id (chat_id , orphaned_ids )
597+ if deleted_message_ids :
598+ existing_map = await ChatMessages .get_messages_map_by_chat_id (chat_id )
599+ if existing_map is not None :
600+ dead_ids = self ._expand_deleted_ids (
601+ existing_map , deleted_message_ids , protected_ids = messages
602+ )
603+ if dead_ids :
604+ await ChatMessages .delete_message_ids_by_chat_id (chat_id , dead_ids )
532605 except Exception as e :
533606 log .warning ('Failed to reconcile chat_message rows for chat %s: %s' , chat_id , e )
534607
0 commit comments