@@ -133,6 +133,7 @@ class ChatFileModel(BaseModel):
133133class ChatForm (BaseModel ):
134134 chat : dict
135135 folder_id : str | None = None
136+ deleted_message_ids : list [str ] | None = None
136137
137138
138139class ChatImportForm (ChatForm ):
@@ -478,6 +479,68 @@ def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
478479 if msg .get ('parentId' ) and msg ['parentId' ] not in messages_map
479480 }
480481
482+ @staticmethod
483+ def _expand_deleted_ids (messages_map : dict , deleted_ids , protected_ids = ()) -> set [str ]:
484+ """Grow an explicit deletion set to its orphaned descendants, keeping any
485+ node that reappears in ``protected_ids`` (reparented children survive).
486+ """
487+ dead = set (deleted_ids )
488+ if not dead :
489+ return dead
490+ changed = True
491+ while changed :
492+ changed = False
493+ for message_id , message in messages_map .items ():
494+ if message_id in dead or message_id in protected_ids :
495+ continue
496+ if message .get ('parentId' ) in dead :
497+ dead .add (message_id )
498+ changed = True
499+ # Intersect so unknown/already-gone ids in deleted_ids don't leak out.
500+ return dead & set (messages_map )
501+
502+ @staticmethod
503+ def merge_history (existing_history : dict , incoming_history : dict , deleted_message_ids = None ) -> dict :
504+ """Merge an incoming history onto the stored one without inferring deletions.
505+
506+ Messages absent from the push are kept — only ``deleted_message_ids`` and
507+ their orphaned descendants are removed — so a stale client can't drop a
508+ message it never saw. Blob counterpart to ``reconcile_messages_by_chat_id``.
509+ """
510+ existing = (existing_history or {}).get ('messages' ) or {}
511+ incoming = (incoming_history or {}).get ('messages' ) or {}
512+ merged = {** existing , ** incoming }
513+
514+ for message_id in ChatTable ._expand_deleted_ids (
515+ merged , deleted_message_ids or [], protected_ids = incoming
516+ ):
517+ merged .pop (message_id , None )
518+
519+ # Rebuild childrenIds from parentId so concurrent branches stay reachable
520+ # (display only; the model context walks parentId, not childrenIds).
521+ for message in merged .values ():
522+ message ['childrenIds' ] = [
523+ child_id for child_id in (message .get ('childrenIds' ) or []) if child_id in merged
524+ ]
525+ for message_id , message in merged .items ():
526+ parent_id = message .get ('parentId' )
527+ if parent_id in merged and message_id not in merged [parent_id ]['childrenIds' ]:
528+ merged [parent_id ]['childrenIds' ].append (message_id )
529+
530+ # Keep the push's currentId; fall back only if it was pruned.
531+ current_id = (incoming_history or {}).get ('currentId' )
532+ if current_id not in merged :
533+ fallback = (existing_history or {}).get ('currentId' )
534+ current_id = fallback if fallback in merged else None
535+
536+ # Preserve any other history keys from the push (fall back to existing),
537+ # then override messages/currentId with the merged result.
538+ return {
539+ ** (incoming_history or existing_history or {}),
540+ 'messages' : merged ,
541+ 'currentId' : current_id ,
542+ }
543+
481544 async def backfill_messages_by_chat_id (self , chat_id : str , user_id : str , messages : dict [str , dict ]) -> None :
482545 """Write messages to the ``chat_message`` table so future lookups
483546 use the fast path. Errors are logged but never raised.
@@ -496,22 +559,28 @@ async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, message
496559 log .warning ('Backfill failed for message %s in chat %s: %s' , message_id , chat_id , e )
497560
498561 async def reconcile_messages_by_chat_id (
499- self , chat_id : str , user_id : str , messages : dict [str , dict ]
562+ self , chat_id : str , user_id : str , messages : dict [str , dict ], deleted_message_ids = None
500563 ) -> None :
501564 """Sync ``chat_message`` rows with the committed JSON blob.
502565
503- Upserts current messages via ``backfill_messages_by_chat_id``
504- and deletes orphaned rows whose message_id no longer appears
505- in the blob. Best-effort: errors are logged but never raised.
566+ Upserts the pushed messages via ``backfill_messages_by_chat_id``, then
567+ prunes only the IDs the client explicitly reported in ``deleted_message_ids``
568+ plus their orphaned descendants. Rows merely missing from the push are never
569+ inferred as deletions — otherwise a stale/lost-update save would silently drop
570+ a still-valid message from the model context. Best-effort: errors are logged
571+ but never raised.
506572 """
507573 try :
508574 await self .backfill_messages_by_chat_id (chat_id , user_id , messages )
509575
510- existing_map = await ChatMessages .get_messages_map_by_chat_id (chat_id )
511- if existing_map is not None :
512- orphaned_ids = set (existing_map .keys ()) - set (messages .keys ())
513- if orphaned_ids :
514- await ChatMessages .delete_message_ids_by_chat_id (chat_id , orphaned_ids )
576+ if deleted_message_ids :
577+ existing_map = await ChatMessages .get_messages_map_by_chat_id (chat_id )
578+ if existing_map is not None :
579+ dead_ids = self ._expand_deleted_ids (
580+ existing_map , deleted_message_ids , protected_ids = messages
581+ )
582+ if dead_ids :
583+ await ChatMessages .delete_message_ids_by_chat_id (chat_id , dead_ids )
515584 except Exception as e :
516585 log .warning ('Failed to reconcile chat_message rows for chat %s: %s' , chat_id , e )
517586
0 commit comments