From 27489f8919b05e30b27bd05d746d90ebc07fb61b Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 4 Jun 2026 17:52:51 +0000 Subject: [PATCH] chat: fix review findings on simplex_name persistence - updateUserProfile no longer writes contact_profiles.simplex_name on the user's own row (the column is reserved for peer claims; the user's broadcastable name lives on contacts.simplex_name via uct.simplex_name). - updateMemberContactProfile_'/Reset_' now write simplex_name; new updateMemberProfileWithConflict / updateContactMemberProfileWithConflict variants run conflict-clear and return the displaced name, with processMemberProfileUpdate emitting CEvtSimplexNameConflict. - createContact_ runs conflict-clear before INSERT to avoid UNIQUE constraint violations on first-write peer collisions, returning the displaced name; createPreparedContact / createDirectContact thread it through to APIPrepareContact and saveConnInfo XInfo for event emission. - groups conflict-clear takes ProfileId directly (avoids the NOT IN (NULL) silent-noop edge case when groups.group_profile_id is ON DELETE SET NULL). - Moves clearConflictingContactProfileSimplexName_ to Shared.hs so createContact_ can call it without inducing a circular import. --- src/Simplex/Chat/Library/Commands.hs | 6 +- src/Simplex/Chat/Library/Subscriber.hs | 18 +++++- src/Simplex/Chat/Store/Direct.hs | 63 +++++++-------------- src/Simplex/Chat/Store/Groups.hs | 78 ++++++++++++++++++-------- src/Simplex/Chat/Store/Profiles.hs | 10 +++- src/Simplex/Chat/Store/Shared.hs | 45 ++++++++++++++- 6 files changed, 144 insertions(+), 76 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e32d2bd0a4..b63273374e 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2033,7 +2033,11 @@ processChatCommand vr nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do - ct <- withStore $ \db -> createPreparedContact db vr user profile accLink welcomeSharedMsgId Nothing + (ct, displaced_) <- withStore $ \db -> createPreparedContact db vr user profile accLink welcomeSharedMsgId Nothing + let Profile {simplexName = pSimplexName} = profile + forM_ ((,) <$> pSimplexName <*> displaced_) $ \(ni, displaced) -> + let Contact {localDisplayName = newLDN} = ct + in toView $ CEvtSimplexNameConflict user ni SNCEContact newLDN displaced void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 8af70af686..1f4bf0d612 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2683,7 +2683,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateBusinessChatProfile gInfo case memberContactId of Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db user m p' + (m', displaced_) <- withStore $ \db -> updateMemberProfileWithConflict db user m p' + let GroupMember {localDisplayName = newLDN} = m' + emitSimplexNameConflict newLDN displaced_ unless (muteEventInChannel gInfo m') $ do forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' @@ -2692,7 +2694,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mCt <- withStore $ \db -> getContact db vr user mContactId if canUpdateProfile mCt then do - (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' + (m', ct', displaced_) <- withStore $ \db -> updateContactMemberProfileWithConflict db user m mCt p' + let Contact {localDisplayName = newLDN} = ct' + emitSimplexNameConflict newLDN displaced_ unless (muteEventInChannel gInfo m') $ do forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' @@ -2709,6 +2713,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure m where allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo + Profile {simplexName = p'SimplexName} = p' + emitSimplexNameConflict newLDN displaced_ = + forM_ ((,) <$> p'SimplexName <*> displaced_) $ \(ni, displaced) -> + toView $ CEvtSimplexNameConflict user ni SNCEContact newLDN displaced updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' @@ -2950,7 +2958,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case chatMsgEvent of XInfo p -> do let Connection {simplexName} = conn' - ct <- withStore $ \db -> createDirectContact db vr user conn' p simplexName + Profile {simplexName = pSimplexName} = p + (ct, displaced_) <- withStore $ \db -> createDirectContact db vr user conn' p simplexName + forM_ ((,) <$> pSimplexName <*> displaced_) $ \(ni, displaced) -> + let Contact {localDisplayName = newLDN} = ct + in toView $ CEvtSimplexNameConflict user ni SNCEContact newLDN displaced toView $ CEvtContactConnecting user ct pure (conn', Nothing) XGrpLinkInv glInv -> do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 8145e0b453..44e7ebc34c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -400,13 +400,18 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createPreparedContact :: DB.Connection -> VersionRangeChat -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe SimplexNameInfo -> ExceptT StoreError IO Contact +-- | Returns (contact, displaced) — displaced is Just the display_name of a +-- contact_profiles row whose peer-claimed simplex_name was cleared to make +-- room for the new contact's claim, so the caller can emit +-- CEvtSimplexNameConflict. +createPreparedContact :: DB.Connection -> VersionRangeChat -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe SimplexNameInfo -> ExceptT StoreError IO (Contact, Maybe ContactName) createPreparedContact db vr user p connLinkToConnect welcomeSharedMsgId simplexName = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs simplexName - getContact db vr user contactId + (contactId, displaced) <- createContact_ db user p ctUserPreferences prepared "" currentTs simplexName + ct <- getContact db vr user contactId + pure (ct, displaced) updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact updatePreparedContactUser @@ -446,13 +451,15 @@ updatePreparedContactUser safeDeleteLDN db user oldLDN getContact db vr newUser contactId -createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection -> Profile -> Maybe SimplexNameInfo -> ExceptT StoreError IO Contact +-- | Returns (contact, displaced) — see createPreparedContact for displaced. +createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection -> Profile -> Maybe SimplexNameInfo -> ExceptT StoreError IO (Contact, Maybe ContactName) createDirectContact db vr user Connection {connId, localAlias} p simplexName = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs simplexName + (contactId, displaced) <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs simplexName liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) - getContact db vr user contactId + ct <- getContact db vr user contactId + pure (ct, displaced) deleteContactConnections :: DB.Connection -> User -> Contact -> IO () deleteContactConnections db User {userId} Contact {contactId} = do @@ -726,38 +733,6 @@ updateContactProfile_' db userId profileId Profile {displayName, fullName, short |] ((displayName, fullName, shortDescr, image, contactLink, simplexName, preferences, peerType, updatedAt) :. (userId, profileId)) --- | Clears simplex_name on any other contact_profiles row that holds the same --- (user_id, simplex_name) so a subsequent UPDATE/INSERT setting that value --- won't trip the partial UNIQUE index. Pass the profileId being updated to --- exclude self; pass Nothing for the pre-INSERT case. Returns the displaced --- row's display_name when a conflict was resolved, for the caller to surface --- as CEvtSimplexNameConflict. Newer-claim-wins matches RSLV semantics: the --- latest broadcast is the canonical assignment. -clearConflictingContactProfileSimplexName_ :: DB.Connection -> UserId -> Maybe ProfileId -> Maybe SimplexNameInfo -> IO (Maybe ContactName) -clearConflictingContactProfileSimplexName_ _ _ _ Nothing = pure Nothing -clearConflictingContactProfileSimplexName_ db userId Nothing (Just simplexName) = - maybeFirstRow fromOnly $ - DB.query - db - [sql| - UPDATE contact_profiles - SET simplex_name = NULL - WHERE user_id = ? AND simplex_name = ? - RETURNING display_name - |] - (userId, simplexName) -clearConflictingContactProfileSimplexName_ db userId (Just profileId) (Just simplexName) = - maybeFirstRow fromOnly $ - DB.query - db - [sql| - UPDATE contact_profiles - SET simplex_name = NULL - WHERE user_id = ? AND simplex_name = ? AND contact_profile_id <> ? - RETURNING display_name - |] - (userId, simplexName, profileId) - -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () updateMemberContactProfileReset_ db userId profileId profile = do @@ -765,15 +740,15 @@ updateMemberContactProfileReset_ db userId profileId profile = do updateMemberContactProfileReset_' db userId profileId profile currentTs updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, simplexName} updatedAt = do DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, simplex_name = ?, preferences = NULL, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + (displayName, fullName, shortDescr, image, simplexName, updatedAt, userId, profileId) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () @@ -782,15 +757,15 @@ updateMemberContactProfile_ db userId profileId profile = do updateMemberContactProfile_' db userId profileId profile currentTs updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, simplexName} updatedAt = do DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, simplex_name = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + (displayName, fullName, shortDescr, image, simplexName, updatedAt, userId, profileId) updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a8569b9913..4adb364993 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -166,7 +166,9 @@ module Simplex.Chat.Store.Groups setMemberContactStartedConnection, resetMemberContactFields, updateMemberProfile, + updateMemberProfileWithConflict, updateContactMemberProfile, + updateContactMemberProfileWithConflict, getXGrpLinkMemReceived, setXGrpLinkMemReceived, createNewUnknownGroupMember, @@ -2379,13 +2381,15 @@ updateGroupProfileWithConflict :: DB.Connection -> User -> GroupInfo -> GroupPro updateGroupProfileWithConflict db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, simplexName, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime - displaced <- clearConflictingGroupProfileSimplexName_ db userId (Just groupId) simplexName + profileId_ <- getGroupProfileId_ + displaced <- clearConflictingGroupProfileSimplexName_ db userId profileId_ simplexName updateGroupProfile_ currentTs pure ((g :: GroupInfo) {groupProfile = p', fullGroupPreferences}, displaced) | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime - displaced <- clearConflictingGroupProfileSimplexName_ db userId (Just groupId) simplexName + profileId_ <- getGroupProfileId_ + displaced <- clearConflictingGroupProfileSimplexName_ db userId profileId_ simplexName updateGroupProfile_ currentTs updateGroup_ ldn currentTs pure $ Right ((g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}, displaced) @@ -2394,6 +2398,13 @@ updateGroupProfileWithConflict db user@User {userId} g@GroupInfo {groupId, local (groupType_, groupLink_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink} -> (Just groupType, Just groupLink) Nothing -> (Nothing, Nothing) + -- groups.group_profile_id is ON DELETE SET NULL; treat the row as having + -- no profile to exclude (Nothing) when it has been nulled out, so the + -- conflict-clear sees every same-user row as a potential collision. + getGroupProfileId_ :: IO (Maybe ProfileId) + getGroupProfileId_ = + fmap (>>= fromOnly) . maybeFirstRow id $ + DB.query db "SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId) updateGroupProfile_ currentTs = DB.execute db @@ -2419,9 +2430,12 @@ updateGroupProfileWithConflict db user@User {userId} g@GroupInfo {groupId, local safeDeleteLDN db user localDisplayName -- | Mirror of clearConflictingContactProfileSimplexName_ for group_profiles. --- Pass the groupId being updated to exclude its underlying group_profile_id --- from the clear; pass Nothing for the pre-INSERT case. See that helper. -clearConflictingGroupProfileSimplexName_ :: DB.Connection -> UserId -> Maybe GroupId -> Maybe SimplexNameInfo -> IO (Maybe GroupName) +-- Pass the group_profile_id being updated to exclude self from the clear; +-- pass Nothing for the pre-INSERT case. The profileId is taken directly +-- (rather than derived from groupId via a NOT IN subquery) because +-- groups.group_profile_id is ON DELETE SET NULL, and NOT IN (NULL) +-- evaluates to UNKNOWN — which would silently no-op the clear. +clearConflictingGroupProfileSimplexName_ :: DB.Connection -> UserId -> Maybe ProfileId -> Maybe SimplexNameInfo -> IO (Maybe GroupName) clearConflictingGroupProfileSimplexName_ _ _ _ Nothing = pure Nothing clearConflictingGroupProfileSimplexName_ db userId Nothing (Just simplexName) = maybeFirstRow fromOnly $ @@ -2434,21 +2448,17 @@ clearConflictingGroupProfileSimplexName_ db userId Nothing (Just simplexName) = RETURNING display_name |] (userId, simplexName) -clearConflictingGroupProfileSimplexName_ db userId (Just groupId) (Just simplexName) = +clearConflictingGroupProfileSimplexName_ db userId (Just profileId) (Just simplexName) = maybeFirstRow fromOnly $ DB.query db [sql| UPDATE group_profiles SET simplex_name = NULL - WHERE user_id = ? - AND simplex_name = ? - AND group_profile_id NOT IN ( - SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? - ) + WHERE user_id = ? AND simplex_name = ? AND group_profile_id <> ? RETURNING display_name |] - (userId, simplexName, userId, groupId) + (userId, simplexName, profileId) updateGroupPreferences :: DB.Connection -> User -> GroupInfo -> GroupPreferences -> IO GroupInfo updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} ps = do @@ -3076,39 +3086,59 @@ setMemberContactStartedConnection db Contact {contactId} = do (BI True, currentTs, contactId) updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db user@User {userId} m p' - | displayName == newName = do - liftIO $ updateMemberContactProfileReset_ db userId profileId p' - pure m {memberProfile = profile} +updateMemberProfile db user m p' = fst <$> updateMemberProfileWithConflict db user m p' + +-- | Like updateMemberProfile but additionally clears the simplex_name on any +-- other contact_profiles row in the same user that already holds the same +-- (user_id, simplex_name) — returning that row's display_name so the caller +-- can emit CEvtSimplexNameConflict. Used by the incoming XInfo (member) path. +updateMemberProfileWithConflict :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO (GroupMember, Maybe ContactName) +updateMemberProfileWithConflict db user@User {userId} m p' + | displayName == newName = liftIO $ do + currentTs <- getCurrentTime + displaced <- clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + updateMemberContactProfileReset_' db userId profileId p' currentTs + pure (m {memberProfile = profile}, displaced) | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime + displaced <- clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName updateMemberContactProfileReset_' db userId profileId p' currentTs DB.execute db "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" (ldn, currentTs, userId, groupMemberId) safeDeleteLDN db user localDisplayName - pure $ Right m {localDisplayName = ldn, memberProfile = profile} + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, displaced) where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m - Profile {displayName = newName} = p' + Profile {displayName = newName, simplexName = profileSimplexName} = p' profile = toLocalProfile profileId p' localAlias updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' - | displayName == newName = do - liftIO $ updateMemberContactProfile_ db userId profileId p' - pure (m {memberProfile = profile}, ct {profile} :: Contact) +updateContactMemberProfile db user m ct p' = (\(m', ct', _) -> (m', ct')) <$> updateContactMemberProfileWithConflict db user m ct p' + +-- | Like updateContactMemberProfile but additionally clears the simplex_name +-- on any other contact_profiles row in the same user that already holds the +-- same (user_id, simplex_name) — returning that row's display_name so the +-- caller can emit CEvtSimplexNameConflict. +updateContactMemberProfileWithConflict :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact, Maybe ContactName) +updateContactMemberProfileWithConflict db user@User {userId} m ct@Contact {contactId} p' + | displayName == newName = liftIO $ do + currentTs <- getCurrentTime + displaced <- clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName + updateMemberContactProfile_' db userId profileId p' currentTs + pure (m {memberProfile = profile}, ct {profile} :: Contact, displaced) | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime + displaced <- clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName updateMemberContactProfile_' db userId profileId p' currentTs updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact, displaced) where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m - Profile {displayName = newName} = p' + Profile {displayName = newName, simplexName = profileSimplexName} = p' profile = toLocalProfile profileId p' localAlias getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 8eb8fecf29..3ba25e700b 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -317,7 +317,7 @@ updateUserAutoAcceptMemberContacts db User {userId} autoAccept = updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do - updateContactProfile_ db userId profileId p' + updateContactProfile_ db userId profileId pNoSimplexName currentTs <- getCurrentTime userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs pure user {profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} @@ -330,7 +330,7 @@ updateUserProfile db user p' db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId profileId p' currentTs + updateContactProfile_' db userId profileId pNoSimplexName currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where @@ -342,6 +342,12 @@ updateUserProfile db user p' userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' + -- contact_profiles.simplex_name is reserved for peer claims received via XInfo. + -- The user's own broadcastable simplex_name lives on contacts.simplex_name + -- (loaded by toUser into User.profile.simplexName via uct.simplex_name); + -- writing it here would (a) collide with peer claims on the partial UNIQUE + -- index, and (b) make a subsequent peer claim displace the user's own row. + pNoSimplexName = (p' :: Profile) {simplexName = Nothing} profile = toLocalProfile profileId p' localAlias fullPreferences = fullPreferences' preferences diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 92e158d1ad..b216f6060b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -421,9 +421,50 @@ createContact db user profile = do currentTs <- liftIO getCurrentTime void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs Nothing -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> Maybe SimplexNameInfo -> ExceptT StoreError IO ContactId +-- | Clears simplex_name on any other contact_profiles row that holds the same +-- (user_id, simplex_name) so a subsequent UPDATE/INSERT setting that value +-- won't trip the partial UNIQUE index. Pass the profileId being updated to +-- exclude self; pass Nothing for the pre-INSERT case. Returns the displaced +-- row's display_name when a conflict was resolved, for the caller to surface +-- as CEvtSimplexNameConflict. Newer-claim-wins matches RSLV semantics: the +-- latest broadcast is the canonical assignment. +clearConflictingContactProfileSimplexName_ :: DB.Connection -> UserId -> Maybe ProfileId -> Maybe SimplexNameInfo -> IO (Maybe ContactName) +clearConflictingContactProfileSimplexName_ _ _ _ Nothing = pure Nothing +clearConflictingContactProfileSimplexName_ db userId Nothing (Just simplexName) = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + UPDATE contact_profiles + SET simplex_name = NULL + WHERE user_id = ? AND simplex_name = ? + RETURNING display_name + |] + (userId, simplexName) +clearConflictingContactProfileSimplexName_ db userId (Just profileId) (Just simplexName) = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + UPDATE contact_profiles + SET simplex_name = NULL + WHERE user_id = ? AND simplex_name = ? AND contact_profile_id <> ? + RETURNING display_name + |] + (userId, simplexName, profileId) + +-- | Inserts a new contact and its profile. Returns the new contactId and, +-- if the peer-claimed Profile.simplexName collided with an existing row +-- (the partial UNIQUE index on contact_profiles.(user_id, simplex_name)), +-- the display_name of the displaced row — newer-claim-wins. The caller +-- is responsible for emitting CEvtSimplexNameConflict on displacement. +createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> Maybe SimplexNameInfo -> ExceptT StoreError IO (ContactId, Maybe ContactName) createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, simplexName = profileSimplexName, peerType, preferences} ctUserPreferences prepared localAlias currentTs simplexName = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + -- Clear any existing peer claim on the same simplex_name before INSERT + -- so the partial UNIQUE index doesn't reject the new row. Pass Nothing + -- as the excluded profileId — there's no self-row yet. + displaced <- clearConflictingContactProfileSimplexName_ db userId Nothing profileSimplexName DB.execute db "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, simplex_name, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" @@ -434,7 +475,7 @@ createContact_ db User {userId} Profile {displayName, fullName, shortDescr, imag "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, simplex_name) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" ((profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared :. Only simplexName) contactId <- insertedRowId db - pure $ Right contactId + pure $ Right (contactId, displaced) newContactUserPrefs :: User -> Profile -> Preferences newContactUserPrefs User {fullPreferences = FullPreferences {timedMessages = userTM}} Profile {preferences} =