diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs
index ad37eba6d7..e22f4ed470 100644
--- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs
+++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs
@@ -46,7 +46,7 @@ runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO ()
runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore =
migrateDBSchema
chatStore
- (toDBOpts dbOptions chatSuffix False)
+ (toDBOpts dbOptions chatSuffix False [])
(Just "sx_directory_migrations")
directorySchemaMigrations
MigrationConfig {confirm, backupPath = Nothing}
diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md
index 9140f3b164..62c5dff3b5 100644
--- a/bots/api/TYPES.md
+++ b/bots/api/TYPES.md
@@ -3349,6 +3349,14 @@ GroupMemberNotFound:
- type: "groupMemberNotFound"
- groupMemberId: int64
+GroupMemberNotFoundByIndex:
+- type: "groupMemberNotFoundByIndex"
+- groupMemberIndex: int64
+
+MemberRelationsVectorNotFound:
+- type: "memberRelationsVectorNotFound"
+- groupMemberId: int64
+
GroupHostMemberNotFound:
- type: "groupHostMemberNotFound"
- groupId: int64
@@ -3361,6 +3369,9 @@ MemberContactGroupMemberNotFound:
- type: "memberContactGroupMemberNotFound"
- contactId: int64
+InvalidMemberRelationUpdate:
+- type: "invalidMemberRelationUpdate"
+
GroupWithoutUser:
- type: "groupWithoutUser"
@@ -3447,9 +3458,6 @@ PendingConnectionNotFound:
- type: "pendingConnectionNotFound"
- connId: int64
-IntroNotFound:
-- type: "introNotFound"
-
UniqueID:
- type: "uniqueID"
diff --git a/cabal.project b/cabal.project
index df5ac1ba24..9f8d821a2e 100644
--- a/cabal.project
+++ b/cabal.project
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
- tag: 3016b929b48d3116f8ee60169c06383ca57f78e0
+ tag: 92a9579e6958026e42a45e32c0e62fca243b4b0f
source-repository-package
type: git
diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md
index c4e8cf7224..087541fda3 100644
--- a/docs/rfcs/2025-11-24-member-relations-vector.md
+++ b/docs/rfcs/2025-11-24-member-relations-vector.md
@@ -72,10 +72,11 @@ note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avo
note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew
note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo
note over A, B: Only Bob forwards between Cath and Dan
+C <<->> D: connect
C ->> B: x.grp.mem.con (connected to Dan)
D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough)
note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected
-note over A, B: Only Bob forwards between Cath and Dan
+note over A, B: Bob stops forwarding between Cath and Dan
```
### Avoid duplicate introductions
diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts
index e67eae39a0..5e3309238b 100644
--- a/packages/simplex-chat-client/types/typescript/src/types.ts
+++ b/packages/simplex-chat-client/types/typescript/src/types.ts
@@ -3735,9 +3735,12 @@ export type StoreError =
| StoreError.GroupNotFoundByName
| StoreError.GroupMemberNameNotFound
| StoreError.GroupMemberNotFound
+ | StoreError.GroupMemberNotFoundByIndex
+ | StoreError.MemberRelationsVectorNotFound
| StoreError.GroupHostMemberNotFound
| StoreError.GroupMemberNotFoundByMemberId
| StoreError.MemberContactGroupMemberNotFound
+ | StoreError.InvalidMemberRelationUpdate
| StoreError.GroupWithoutUser
| StoreError.DuplicateGroupMember
| StoreError.GroupAlreadyJoined
@@ -3761,7 +3764,6 @@ export type StoreError =
| StoreError.ConnectionNotFoundById
| StoreError.ConnectionNotFoundByMemberId
| StoreError.PendingConnectionNotFound
- | StoreError.IntroNotFound
| StoreError.UniqueID
| StoreError.LargeMsg
| StoreError.InternalError
@@ -3820,9 +3822,12 @@ export namespace StoreError {
| "groupNotFoundByName"
| "groupMemberNameNotFound"
| "groupMemberNotFound"
+ | "groupMemberNotFoundByIndex"
+ | "memberRelationsVectorNotFound"
| "groupHostMemberNotFound"
| "groupMemberNotFoundByMemberId"
| "memberContactGroupMemberNotFound"
+ | "invalidMemberRelationUpdate"
| "groupWithoutUser"
| "duplicateGroupMember"
| "groupAlreadyJoined"
@@ -3846,7 +3851,6 @@ export namespace StoreError {
| "connectionNotFoundById"
| "connectionNotFoundByMemberId"
| "pendingConnectionNotFound"
- | "introNotFound"
| "uniqueID"
| "largeMsg"
| "internalError"
@@ -3988,6 +3992,16 @@ export namespace StoreError {
groupMemberId: number // int64
}
+ export interface GroupMemberNotFoundByIndex extends Interface {
+ type: "groupMemberNotFoundByIndex"
+ groupMemberIndex: number // int64
+ }
+
+ export interface MemberRelationsVectorNotFound extends Interface {
+ type: "memberRelationsVectorNotFound"
+ groupMemberId: number // int64
+ }
+
export interface GroupHostMemberNotFound extends Interface {
type: "groupHostMemberNotFound"
groupId: number // int64
@@ -4003,6 +4017,10 @@ export namespace StoreError {
contactId: number // int64
}
+ export interface InvalidMemberRelationUpdate extends Interface {
+ type: "invalidMemberRelationUpdate"
+ }
+
export interface GroupWithoutUser extends Interface {
type: "groupWithoutUser"
}
@@ -4112,10 +4130,6 @@ export namespace StoreError {
connId: number // int64
}
- export interface IntroNotFound extends Interface {
- type: "introNotFound"
- }
-
export interface UniqueID extends Interface {
type: "uniqueID"
}
diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix
index 7acb51e97c..406ef76cc5 100644
--- a/scripts/nix/sha256map.nix
+++ b/scripts/nix/sha256map.nix
@@ -1,5 +1,5 @@
{
- "https://github.com/simplex-chat/simplexmq.git"."3016b929b48d3116f8ee60169c06383ca57f78e0" = "1gvv96w1ag8xh19an5j50isjw0yq1cbsywii5s1adaplr6fw34mb";
+ "https://github.com/simplex-chat/simplexmq.git"."92a9579e6958026e42a45e32c0e62fca243b4b0f" = "0hacyi4hsbca55l4n2n4pcsmsfvh2jaw681j6106y5mkillmb3fv";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 24ccf24ded..bc49fc0b45 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -123,6 +123,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync
Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade
Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector
+ Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2
else
exposed-modules:
Simplex.Chat.Archive
@@ -269,6 +270,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync
Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade
Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector
+ Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2
other-modules:
Paths_simplex_chat
hs-source-dirs:
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index 9b711c2b50..93132fb413 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -118,8 +118,8 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
createChatDatabase :: ChatDbOpts -> MigrationConfig -> IO (Either MigrationError ChatDatabase)
createChatDatabase chatDbOpts migrationConfig = runExceptT $ do
- chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) migrationConfig
- agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) migrationConfig
+ chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False chatDBFunctions) migrationConfig
+ agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False []) migrationConfig
pure ChatDatabase {chatStore, agentStore}
newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController
diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs
index 630cad4e70..7f4ee238b1 100644
--- a/src/Simplex/Chat/Library/Commands.hs
+++ b/src/Simplex/Chat/Library/Commands.hs
@@ -167,6 +167,9 @@ startChatController mainApp enableSndFiles = do
runExceptT (syncConnections' users) >>= \case
Left e -> liftIO $ putStrLn $ "Error synchronizing connections: " <> show e
Right _ -> pure ()
+ runExceptT migrateMemberRelations >>= \case
+ Left e -> liftIO $ putStrLn $ "Error migrating member relations: " <> show e
+ Right _ -> pure ()
restoreCalls
s <- asks agentAsync
readTVarIO s >>= maybe (start s users) (pure . fst)
@@ -178,6 +181,10 @@ startChatController mainApp enableSndFiles = do
(userDiff, connDiff) <- withAgent (\a -> syncConnections a aUserIds connIds)
withFastStore' setConnectionsSyncTs
toView $ CEvtConnectionsDiff (AgentUserId <$> userDiff) (AgentConnId <$> connDiff)
+ migrateMemberRelations =
+ when mainApp $
+ whenM (withStore' hasMembersWithoutVector) $
+ void $ forkIO runRelationsVectorMigration
start s users = do
a1 <- async agentSubscriber
a2 <-
@@ -4163,6 +4170,21 @@ agentSubscriber = do
type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId))
+runRelationsVectorMigration :: CM ()
+runRelationsVectorMigration = do
+ liftIO $ threadDelay' 5000000 -- 5 seconds (initial delay)
+ migrateMembers
+ where
+ stepDelay = 1000000 -- 1 second
+ migrateMembers = flip catchAllErrors eToView $ do
+ lift waitChatStartedAndActivated
+ gmIds <- withStore' getGMsWithoutVectorIds
+ forM_ gmIds $ \gmId -> do
+ lift waitChatStartedAndActivated
+ withStore' (`migrateMemberRelationsVector'` gmId) `catchAllErrors` eToView
+ liftIO $ threadDelay' stepDelay
+ unless (null gmIds) migrateMembers
+
cleanupManager :: CM ()
cleanupManager = do
interval <- asks (cleanupManagerInterval . config)
diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs
index 711a24c79e..523224c76d 100644
--- a/src/Simplex/Chat/Library/Internal.hs
+++ b/src/Simplex/Chat/Library/Internal.hs
@@ -73,6 +73,7 @@ import Simplex.Chat.Store.Messages
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
+import Simplex.Chat.Types.MemberRelations
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Util (encryptFile, shuffle)
@@ -1024,65 +1025,84 @@ introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember ->
introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do
forM_ (memberConn m) $ \mConn -> do
let msg =
- if (maxVersion (memberChatVRange m) >= groupKnockingVersion)
+ if maxVersion (memberChatVRange m) >= groupKnockingVersion
then XGrpLinkAcpt GAPendingReview memberRole memberId
else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing
void $ sendDirectMemberMessage mConn msg groupId
modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo
- let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs
+ let rcpModMs = filter shouldIntroduce modMs
introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m)
+ where
+ shouldIntroduce :: GroupMember -> Bool
+ shouldIntroduce mem =
+ memberCurrent mem
+ && groupMemberId' mem /= groupMemberId' m
+ && maxVersion (memberChatVRange mem) >= groupKnockingVersion
introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceToAll vr user gInfo m = do
- members <- withStore' $ \db -> getGroupMembersForIntroduction db vr user gInfo m
- let recipients = filter memberCurrent members
+ members <- withStore' $ \db -> getGroupMembers db vr user gInfo
+ vector_ <- withStore' (`getMemberRelationsVector_` m)
+ let recipients = filter (shouldIntroduce vector_) members
introduceMember vr user gInfo m recipients Nothing
+ where
+ shouldIntroduce :: Maybe ByteString -> GroupMember -> Bool
+ shouldIntroduce vector_ m' =
+ memberCurrent m'
+ && groupMemberId' m' /= groupMemberId' m
+ && maybe True (\v -> getRelation (indexInGroup m') v == MRNew) vector_
introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceToRemaining vr user gInfo m = do
- (members, introducedGMIds) <-
- withStore' $ \db -> (,) <$> getGroupMembersForIntroduction db vr user gInfo m <*> getIntroducedGroupMemberIds db m
- let recipients = filter (introduceMemP introducedGMIds) members
+ members <- withStore' $ \db -> getGroupMembers db vr user gInfo
+ vector_ <- withStore' (`getMemberRelationsVector_` m)
+ recipients <- filterRecipients vector_ members
introduceMember vr user gInfo m recipients Nothing
where
- introduceMemP introducedGMIds mem =
- memberCurrent mem
- && groupMemberId' mem `notElem` introducedGMIds
- && groupMemberId' mem /= groupMemberId' m
+ filterRecipients :: Maybe ByteString -> [GroupMember] -> CM [GroupMember]
+ filterRecipients vector_ members = do
+ newRelation <- case vector_ of
+ Nothing -> do
+ introducedGMIds <- S.fromList <$> withStore' (`getIntroducedGroupMemberIds` m)
+ pure $ \m' -> groupMemberId' m' `S.notMember` introducedGMIds
+ Just vec -> pure $ \m' -> getRelation (indexInGroup m') vec == MRNew
+ pure $ filter (\m' -> groupMemberId' m' /= groupMemberId' m && memberCurrent m' && newRelation m') members
introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM ()
introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active"
-introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do
- void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo m) msgScope
+introduceMember vr user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do
+ void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo toMember) msgScope
sendIntroductions introduceToMembers
where
- sendIntroductions members = do
- intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m
- shuffledIntros <- liftIO $ shuffleIntros intros
- if m `supportsVersion` batchSendVersion
+ sendIntroductions reMembers = do
+ updateToMemberVector reMembers
+ reMembers' <- withStore' $ \db -> createIntrosOrUpdateVectors db vr reMembers toMember
+ shuffledReMembers <- liftIO $ shuffleMembers reMembers'
+ if toMember `supportsVersion` batchSendVersion
then do
- let events = map (memberIntro . reMember) shuffledIntros
+ let events = map memberIntro shuffledReMembers
forM_ (L.nonEmpty events) $ \events' ->
sendGroupMemberMessages user conn events' groupId
- else forM_ shuffledIntros $ \intro ->
- processIntro intro `catchAllErrors` eToView
+ else forM_ shuffledReMembers $ \reMember ->
+ void $ sendDirectMemberMessage conn (memberIntro reMember) groupId
+ updateToMemberVector :: [GroupMember] -> CM ()
+ updateToMemberVector reMembers = do
+ let relations = map (\GroupMember {indexInGroup} -> (indexInGroup, (IDReferencedIntroduced, MRIntroduced))) reMembers
+ withStore' $ \db -> setMemberVectorNewRelations db toMember relations
memberIntro :: GroupMember -> ChatMsgEvent 'Json
memberIntro reMember =
let mInfo = memberInfo gInfo reMember
mRestrictions = memberRestrictions reMember
in XGrpMemIntro mInfo mRestrictions
- shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro]
- shuffleIntros intros = do
- let (admins, others) = partition isAdmin intros
+ shuffleMembers :: [GroupMember] -> IO [GroupMember]
+ shuffleMembers reMembers = do
+ let (admins, others) = partition isAdmin reMembers
(admPics, admNoPics) = partition hasPicture admins
(othPics, othNoPics) = partition hasPicture others
mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics]
where
- isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin
- hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image
- processIntro intro@GroupMemberIntro {introId} = do
- void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId
- withStore' $ \db -> updateIntroStatus db introId GMIntroSent
+ isAdmin GroupMember {memberRole} = memberRole >= GRAdmin
+ hasPicture GroupMember {memberProfile = LocalProfile {image}} = isJust image
userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile
userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks
@@ -2047,8 +2067,8 @@ readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {c
| otherwise = Nothing
readyMemberConn GroupMember {activeConn = Nothing} = Nothing
-sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM ()
-sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do
+sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe GroupMemberIntro -> CM () -> CM ()
+sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent intro_ postDeliver = do
msg <- createSndMessage chatMsgEvent (GroupId groupId)
messageMember msg `catchAllErrors` eToView
where
@@ -2056,7 +2076,7 @@ sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} c
messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case
MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver
MSASendBatched conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver
- MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_
+ MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId (introId <$> intro_)
MSAForwarded -> pure ()
-- TODO ensure order - pending messages interleave with user input messages
diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs
index 5073da9c77..27f3a4f61c 100644
--- a/src/Simplex/Chat/Library/Subscriber.hs
+++ b/src/Simplex/Chat/Library/Subscriber.hs
@@ -28,7 +28,7 @@ import Data.Either (lefts, partitionEithers, rights)
import Data.Foldable (foldr')
import Data.Functor (($>))
import Data.Int (Int64)
-import Data.List (find, foldl')
+import Data.List (find)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
@@ -62,6 +62,7 @@ import Simplex.Chat.Store.Messages
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
+import Simplex.Chat.Types.MemberRelations
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.FileTransfer.Description (ValidFileDescription)
@@ -2615,13 +2616,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case
Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist"
Right reMember -> do
- introId <- withStore $ \db -> do
- GroupMemberIntro {introId} <- getIntroduction db reMember m
- liftIO $ updateIntroStatus db introId GMIntroInvReceived
- pure introId
- sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) (Just introId) $
- withStore' $
- \db -> updateIntroStatus db introId GMIntroInvForwarded
+ intro_ <- withStore' $ \db -> getIntroduction db reMember m
+ update intro_ GMIntroInvReceived
+ sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) intro_ $
+ update intro_ GMIntroInvForwarded
+ where
+ update (Just GroupMemberIntro {introId}) status = withStore' $ \db -> updateIntroStatus db introId status
+ update Nothing _ = pure ()
_ -> messageError "x.grp.mem.inv can be only sent by invitee member"
xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM ()
@@ -2715,45 +2716,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
blocked = mrsBlocked restriction
xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM ()
- xGrpMemCon gInfo sendingMember memId = do
- refMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId
- case (memberCategory sendingMember, memberCategory refMember) of
- (GCInviteeMember, GCInviteeMember) ->
- withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case
- Right intro -> inviteeXGrpMemCon intro
- Left _ ->
- withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case
- Right intro -> forwardMemberXGrpMemCon intro
- Left _ -> messageWarning "x.grp.mem.con: no introduction"
- (GCInviteeMember, _) ->
- withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case
- Right intro -> inviteeXGrpMemCon intro
- Left _ -> messageWarning "x.grp.mem.con: no introduction"
- (_, GCInviteeMember) ->
- withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case
- Right intro -> forwardMemberXGrpMemCon intro
- Left _ -> messageWarning "x.grp.mem.con: no introductiosupportn"
- -- Note: we can allow XGrpMemCon to all member categories if we decide to support broader group forwarding,
- -- deduplication (see saveGroupRcvMsg, saveGroupFwdRcvMsg) already supports sending XGrpMemCon
- -- to any forwarding member, not only host/inviting member;
- -- database would track all members connections then
- -- (currently it's done via group_member_intros for introduced connections only)
- _ ->
- messageWarning "x.grp.mem.con: neither member is invitee"
- where
- inviteeXGrpMemCon :: GroupMemberIntro -> CM ()
- inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
- GMIntroReConnected -> updateStatus introId GMIntroConnected
- GMIntroToConnected -> pure ()
- GMIntroConnected -> pure ()
- _ -> updateStatus introId GMIntroToConnected
- forwardMemberXGrpMemCon :: GroupMemberIntro -> CM ()
- forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
- GMIntroToConnected -> updateStatus introId GMIntroConnected
- GMIntroReConnected -> pure ()
- GMIntroConnected -> pure ()
- _ -> updateStatus introId GMIntroReConnected
- updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status
+ xGrpMemCon gInfo sendingMem memId = do
+ refMem <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId
+ withStore' (`migrateMemberRelationsVector` sendingMem)
+ withStore' (`migrateMemberRelationsVector` refMem)
+ -- Updating vectors in separate transactions to avoid deadlocks.
+ withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected
+ withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages chatMsg msg brokerTs forwarded = do
@@ -3238,10 +3207,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
DJSMemberSupport scopeGMId -> do
-- for member support scope we just load all recipients in one go, without cursor
modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo
- let moderatorFilter mem =
- memberCurrent mem
- && maxVersion (memberChatVRange mem) >= groupKnockingVersion
- && Just (groupMemberId' mem) /= singleSenderGMId_
+ let moderatorFilter m =
+ memberCurrent m
+ && maxVersion (memberChatVRange m) >= groupKnockingVersion
+ && Just (groupMemberId' m) /= singleSenderGMId_
modMs' = filter moderatorFilter modMs
mems <-
if Just scopeGMId == singleSenderGMId_
@@ -3255,42 +3224,30 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays"
Just singleSenderGMId -> do
sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId
- mems <- buildMemberList sender
- unless (null mems) $ deliver body mems
+ ms <- buildMemberList sender
+ unless (null ms) $ deliver body ms
where
- buildMemberList sender = case jobScope of
- DJSGroup {jobSpec}
- | jobSpecImpliedPending jobSpec ->
- filter memberCurrentOrPending <$> getAllIntroducedAndInvited
- | otherwise ->
- filter memberCurrent <$> getAllIntroducedAndInvited
- DJSMemberSupport scopeGMId -> do
- -- moderators introduced to this invited member
- introducedModMs <-
- if memberCategory sender == GCInviteeMember
- then withStore' $ \db -> getForwardIntroducedModerators db vr user sender
- else pure []
- -- invited moderators to which this member was introduced
- invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user sender
- let modMs = introducedModMs <> invitedModMs
- modMs' = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs
- if scopeGMId == groupMemberId' sender
- then pure modMs'
- else
- withStore' (\db -> getForwardScopeMember db vr user sender scopeGMId) >>= \case
- Just scopeMem -> pure $ scopeMem : modMs'
- _ -> pure modMs'
- where
- getAllIntroducedAndInvited = do
- ChatConfig {highlyAvailable} <- asks config
- -- members introduced to this invited member
- introducedMembers <-
- if memberCategory sender == GCInviteeMember
- then withStore' $ \db -> getForwardIntroducedMembers db vr user sender highlyAvailable
- else pure []
- -- invited members to which this member was introduced
- invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user sender highlyAvailable
- pure $ introducedMembers <> invitedMembers
+ buildMemberList sender = do
+ vec <- withStore $ \db -> migrateGetMemberRelationsVector db sender
+ -- this excludes the sender
+ let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec
+ case jobScope of
+ DJSGroup {jobSpec} -> do
+ ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs
+ pure $ filter shouldForwardTo ms
+ where
+ shouldForwardTo m
+ | jobSpecImpliedPending jobSpec = memberCurrentOrPending m
+ | otherwise = memberCurrent m
+ DJSMemberSupport scopeGMId -> do
+ ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs
+ pure $ filter shouldForwardTo ms
+ where
+ shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m
+ currentModerator m@GroupMember {memberRole} =
+ memberRole >= GRModerator
+ && memberCurrent m
+ && maxVersion (memberChatVRange m) >= groupKnockingVersion
where
deliver :: ByteString -> [GroupMember] -> CM ()
deliver msgBody mems =
diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs
index b22cfebcdd..3c8f170b0d 100644
--- a/src/Simplex/Chat/Mobile.hs
+++ b/src/Simplex/Chat/Mobile.hs
@@ -295,8 +295,8 @@ chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigra
chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do
confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm
let migrationConfig = MigrationConfig confirmMigrations (Just "")
- chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) migrationConfig
- agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) migrationConfig
+ chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig
+ agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig
liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore}
where
opts = mobileChatOpts $ removeDbKey chatDbOpts
diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs
index 13af13b20a..c74ae37750 100644
--- a/src/Simplex/Chat/Options/Postgres.hs
+++ b/src/Simplex/Chat/Options/Postgres.hs
@@ -58,8 +58,8 @@ migrationBackupPathP = pure Nothing
dbString :: ChatDbOpts -> String
dbString ChatDbOpts {dbConnstr} = dbConnstr
-toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts
-toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey =
+toDBOpts :: ChatDbOpts -> String -> Bool -> [()] -> DBOpts
+toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey _dbFunctions =
DBOpts
{ connstr = B.pack dbConnstr,
schema = B.pack $ if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix,
@@ -73,6 +73,9 @@ chatSuffix = "_chat_schema"
agentSuffix :: String
agentSuffix = "_agent_schema"
+chatDBFunctions :: [()]
+chatDBFunctions = []
+
mobileDbOpts :: CString -> CString -> IO ChatDbOpts
mobileDbOpts schemaPrefix connstr = do
dbSchemaPrefix <- peekCString schemaPrefix
diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs
index 8591f0801c..7e25bb2217 100644
--- a/src/Simplex/Chat/Options/SQLite.hs
+++ b/src/Simplex/Chat/Options/SQLite.hs
@@ -11,7 +11,9 @@ import qualified Data.ByteArray as BA
import qualified Data.ByteString.Char8 as B
import Foreign.C.String
import Options.Applicative
+import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector
import Simplex.Messaging.Agent.Store.Interface (DBOpts (..))
+import Simplex.Messaging.Agent.Store.SQLite.Common (SQLiteFuncDef (..), SQLiteFuncPtrs (..))
import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..))
import System.FilePath (combine)
@@ -70,10 +72,11 @@ migrationBackupPathP =
dbString :: ChatDbOpts -> String
dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
-toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts
-toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do
+toDBOpts :: ChatDbOpts -> String -> Bool -> [SQLiteFuncDef] -> DBOpts
+toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey dbFunctions = do
DBOpts
{ dbFilePath = dbFilePrefix <> dbSuffix,
+ dbFunctions,
dbKey,
keepKey,
vacuum = vacuumOnMigration,
@@ -86,6 +89,12 @@ chatSuffix = "_chat.db"
agentSuffix :: String
agentSuffix = "_agent.db"
+chatDBFunctions :: [SQLiteFuncDef]
+chatDBFunctions =
+ [ SQLiteFuncDef "migrate_relations_vector" 3 (SQLiteAggrPtrs sqliteMemberRelationsStepPtr sqliteMemberRelationsFinalPtr),
+ SQLiteFuncDef "set_member_vector_new_relation" 4 (SQLiteFuncPtr True sqliteSetMemberVectorNewRelationPtr)
+ ]
+
mobileDbOpts :: CString -> CString -> IO ChatDbOpts
mobileDbOpts fp key = do
dbFilePrefix <- peekCString fp
diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs
index 30fffac0e7..c1da436f04 100644
--- a/src/Simplex/Chat/Store/Delivery.hs
+++ b/src/Simplex/Chat/Store/Delivery.hs
@@ -185,7 +185,7 @@ getNextDeliveryTasks db gInfo task =
| otherwise =
-- For fully connected groups we guarantee a singleSenderGMId for a delivery job by additionally filtering
-- on sender_group_member_id here, so that the job can then retrieve less members as recipients,
- -- optimizing for this single sender (see processDeliveryJob -> getForwardIntroducedMembers, etc.).
+ -- optimizing for this single sender (see processDeliveryJob -> fully connected group branch).
-- We do this optimization in the job to decrease load on admins using mobile devices for clients.
map fromOnly
<$> DB.query
diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs
index c04a31c0a9..44dcd3c7f9 100644
--- a/src/Simplex/Chat/Store/Groups.hs
+++ b/src/Simplex/Chat/Store/Groups.hs
@@ -58,11 +58,13 @@ module Simplex.Chat.Store.Groups
getMentionedGroupMember,
getMentionedMemberByMemberId,
getGroupMemberById,
+ getGroupMemberByIndex,
getGroupMemberByMemberId,
getGroupMemberIdViaMemberId,
getScopeMemberIdViaMemberId,
getGroupMembers,
- getGroupMembersForIntroduction,
+ getGroupMembersByIndexes,
+ getSupportScopeMembersByIndexes,
getGroupModerators,
getGroupRelays,
getGroupMembersForExpiration,
@@ -98,14 +100,17 @@ module Simplex.Chat.Store.Groups
deleteGroupMemberConnection,
updateGroupMemberRole,
createIntroductions,
+ createIntrosOrUpdateVectors,
+ setMemberVectorNewRelations,
+ setMembersVectorsNewRelation,
+ setMemberVectorRelationConnected,
+ migrateGetMemberRelationsVector,
+ migrateMemberRelationsVector,
+ migrateMemberRelationsVector',
+ getMemberRelationsVector_,
updateIntroStatus,
getIntroduction,
getIntroducedGroupMemberIds,
- getForwardIntroducedMembers,
- getForwardIntroducedModerators,
- getForwardInvitedMembers,
- getForwardInvitedModerators,
- getForwardScopeMember,
createIntroReMember,
createIntroToMemberContact,
getMatchingContacts,
@@ -146,6 +151,8 @@ module Simplex.Chat.Store.Groups
setGroupChatTTL,
getGroupChatTTL,
getUserGroupsToExpire,
+ hasMembersWithoutVector,
+ getGMsWithoutVectorIds,
updateGroupAlias,
)
where
@@ -155,8 +162,11 @@ import Control.Monad.Except
import Control.Monad.IO.Class
import Crypto.Random (ChaChaDRG)
import Data.Bifunctor (second)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as B
import Data.Char (toLower)
import Data.Either (rights)
+import Data.Foldable (foldrM)
import Data.Int (Int64)
import Data.List (partition, sortOn)
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)
@@ -170,6 +180,7 @@ import Simplex.Chat.Protocol hiding (Binary)
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
+import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), setNewRelations, setRelationConnected, toIntroDirInt, toRelationInt)
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
@@ -180,11 +191,12 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff)
import Simplex.Messaging.Protocol (SubscriptionMode (..))
-import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>))
+import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>=), (<$$>))
import Simplex.Messaging.Version
import UnliftIO.STM
#if defined(dbPostgres)
-import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
+import qualified Data.Set as S
+import Database.PostgreSQL.Simple (In (..), Only (..), Query, (:.) (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
@@ -511,12 +523,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId)
+ ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId)
:. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt)
:. (minV, maxV)
)
@@ -530,12 +542,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId)
+ ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId)
:. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt)
:. (minV, maxV)
)
@@ -570,11 +582,11 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown)
+ ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
)
insertedRowId db
@@ -766,11 +778,11 @@ createGroupViaLink'
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown)
+ ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
)
insertedRowId db
@@ -994,6 +1006,22 @@ getGroupMemberById db vr user@User {userId} groupMemberId =
(groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?")
(groupMemberId, userId)
+getGroupMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember
+getGroupMemberByIndex db vr user GroupInfo {groupId} indexInGroup =
+ ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $
+ DB.query
+ db
+ (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?")
+ (groupId, indexInGroup)
+
+getSupportScopeMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember
+getSupportScopeMemberByIndex db vr user GroupInfo {groupId} scopeGMId indexInGroup =
+ ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $
+ DB.query
+ db
+ (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)")
+ (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId)
+
getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember
getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId =
ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $
@@ -1017,20 +1045,38 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId =
(userId, groupId, memberId)
getGroupMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember]
-getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do
+getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} =
map (toContactMember vr user)
<$> DB.query
db
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)")
(userId, groupId, userContactId)
-getGroupMembersForIntroduction :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> IO [GroupMember]
-getGroupMembersForIntroduction db vr user@User {userId, userContactId} GroupInfo {groupId} _introduced@GroupMember {indexInGroup} = do
- map (toContactMember vr user)
- <$> DB.query
+getGroupMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> [Int64] -> IO [GroupMember]
+getGroupMembersByIndexes db vr user gInfo indexesInGroup = do
+#if defined(dbPostgres)
+ let GroupInfo {groupId} = gInfo
+ map (toContactMember vr user) <$>
+ DB.query
db
- (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ?")
- (userId, groupId, userContactId, indexInGroup)
+ (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?")
+ (groupId, In indexesInGroup)
+#else
+ rights <$> mapM (runExceptT . getGroupMemberByIndex db vr user gInfo) indexesInGroup
+#endif
+
+getSupportScopeMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember]
+getSupportScopeMembersByIndexes db vr user gInfo scopeGMId indexesInGroup = do
+#if defined(dbPostgres)
+ let GroupInfo {groupId} = gInfo
+ map (toContactMember vr user) <$>
+ DB.query
+ db
+ (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)")
+ (groupId, In indexesInGroup, GRModerator, GRAdmin, GROwner, scopeGMId)
+#else
+ rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db vr user gInfo scopeGMId) indexesInGroup
+#endif
getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember]
getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do
@@ -1141,12 +1187,12 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId,
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId)
+ ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, invitedByGroupMemberId)
:. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt)
:. (minV, maxV)
)
@@ -1169,12 +1215,12 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership)
+ ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership)
:. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt)
:. (minV, maxV)
)
@@ -1212,12 +1258,12 @@ createJoiningMember
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership)
+ ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership)
:. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs)
:. (minV, maxV)
)
@@ -1291,12 +1337,12 @@ createBusinessRequestGroup
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership)
+ ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
:. (minV, maxV)
)
@@ -1497,12 +1543,12 @@ createNewMember_
db
[sql|
INSERT INTO group_members
- (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id,
+ (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId)
+ ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, memRestriction, invitedById, memInvitedByGroupMemberId)
:. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt)
:. (minV, maxV)
)
@@ -1561,27 +1607,196 @@ updateGroupMemberRole :: DB.Connection -> User -> GroupMember -> GroupMemberRole
updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole =
DB.execute db "UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?" (memRole, userId, groupMemberId)
-createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro]
-createIntroductions db chatV members toMember = do
- let reMembers = filter (\m -> memberCurrent m && groupMemberId' m /= groupMemberId' toMember) members
- if null reMembers
- then pure []
- else do
+createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMember]
+createIntroductions db chatV reMembers toMember
+ | null reMembers = pure []
+ | otherwise = do
currentTs <- getCurrentTime
- mapM (insertIntro_ currentTs) reMembers
+ catMaybes <$> mapM (createIntro_ currentTs) reMembers
where
- insertIntro_ :: UTCTime -> GroupMember -> IO GroupMemberIntro
- insertIntro_ ts reMember = do
- DB.execute
+ createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMember)
+ createIntro_ ts reMember =
+ -- when members connect concurrently, host would try to create introductions between them in both directions;
+ -- this check avoids creating second (redundant) introduction
+ checkInverseIntro >>= \case
+ Just _ -> pure Nothing
+ Nothing -> do
+ DB.execute
+ db
+ [sql|
+ INSERT INTO group_member_intros
+ (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at)
+ VALUES (?,?,?,?,?,?)
+ |]
+ (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts)
+ pure $ Just reMember
+ where
+ checkInverseIntro :: IO (Maybe Int64)
+ checkInverseIntro =
+ maybeFirstRow fromOnly $
+ DB.query
+ db
+ "SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1"
+ (groupMemberId' toMember, groupMemberId' reMember)
+
+-- Create introductions for members without vectors and update vectors for members with vectors.
+-- Partitioning and updates happen in same transaction to avoid race conditions.
+createIntrosOrUpdateVectors :: DB.Connection -> VersionRangeChat -> [GroupMember] -> GroupMember -> IO [GroupMember]
+createIntrosOrUpdateVectors db vr reMembers toMember
+ | null reMembers = pure []
+ | otherwise = do
+ (memsWithVec, memsWithoutVec) <- partitionByVector reMembers
+ let GroupMember {indexInGroup} = toMember
+ setMembersVectorsNewRelation db memsWithVec indexInGroup IDSubjectIntroduced MRIntroduced
+ memsWithoutVec' <- createIntroductions db (maxVersion vr) memsWithoutVec toMember
+ pure $ memsWithoutVec' <> memsWithVec
+ where
+ partitionByVector :: [GroupMember] -> IO ([GroupMember], [GroupMember])
+#if defined(dbPostgres)
+ partitionByVector members = do
+ let memberIds = map groupMemberId' members
+ -- Lock rows first to ensure partitioning doesn't change in case of concurrent updates
+ _ :: [Only Int] <-
+ DB.query
+ db
+ "SELECT 1 FROM group_members WHERE group_member_id IN ? FOR UPDATE"
+ (Only $ In memberIds)
+ memberIdsWithVec <- S.fromList . map fromOnly <$>
+ DB.query
+ db
+ "SELECT group_member_id FROM group_members WHERE group_member_id IN ? AND member_relations_vector IS NOT NULL"
+ (Only $ In memberIds)
+ pure $ partition (\m -> groupMemberId' m `S.member` memberIdsWithVec) members
+#else
+ partitionByVector = foldrM checkMember ([], [])
+ where
+ checkMember m (withVec, withoutVec) = do
+ hasVec <- isJust <$> maybeFirstRow fromOnly
+ (DB.query db "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" (Only $ groupMemberId' m) :: IO [Only Int64])
+ pure $ if hasVec then (m : withVec, withoutVec) else (withVec, m : withoutVec)
+#endif
+
+setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO ()
+setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do
+ v_ <- maybeFirstRow fromOnly $
+ DB.query
+ db
+#if defined(dbPostgres)
+ "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? FOR UPDATE"
+#else
+ "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?"
+#endif
+ (Only groupMemberId)
+ let v' = setNewRelations relations $ fromMaybe B.empty v_
+ currentTs <- getCurrentTime
+ DB.execute
+ db
+ [sql|
+ UPDATE group_members
+ SET member_relations_vector = ?, updated_at = ?
+ WHERE group_member_id = ?
+ |]
+ (Binary v', currentTs, groupMemberId)
+
+setMembersVectorsNewRelation :: DB.Connection -> [GroupMember] -> Int64 -> IntroductionDirection -> MemberRelation -> IO ()
+setMembersVectorsNewRelation db members idx dir status = do
+ currentTs <- getCurrentTime
+#if defined(dbPostgres)
+ let memberIds = map groupMemberId' members
+ DB.execute
+ db
+ "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id IN ?"
+ (idx, toIntroDirInt dir, toRelationInt status, currentTs, In memberIds)
+#else
+ forM_ members $ \GroupMember {groupMemberId} ->
+ DB.execute
+ db
+ "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ?"
+ (idx, toIntroDirInt dir, toRelationInt status, currentTs, groupMemberId)
+#endif
+
+setMemberVectorRelationConnected :: DB.Connection -> GroupMember -> GroupMember -> MemberRelation -> ExceptT StoreError IO ()
+setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {indexInGroup} newStatus = do
+ when (newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected) $
+ throwError SEInvalidMemberRelationUpdate
+ v <- ExceptT $
+ firstRow fromOnly (SEMemberRelationsVectorNotFound groupMemberId) $
+ DB.query
db
- [sql|
- INSERT INTO group_member_intros
- (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at)
- VALUES (?,?,?,?,?,?)
- |]
- (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts)
- introId <- insertedRowId db
- pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending}
+#if defined(dbPostgres)
+ "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL FOR UPDATE"
+#else
+ "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL"
+#endif
+ (Only groupMemberId)
+ let v' = setRelationConnected indexInGroup newStatus v
+ currentTs <- liftIO getCurrentTime
+ liftIO $ DB.execute
+ db
+ [sql|
+ UPDATE group_members
+ SET member_relations_vector = ?, updated_at = ?
+ WHERE group_member_id = ?
+ |]
+ (Binary v', currentTs, groupMemberId)
+
+migrateGetMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString
+migrateGetMemberRelationsVector db m@GroupMember {groupMemberId} = do
+ liftIO $ migrateMemberRelationsVector db m
+ ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $
+ DB.query
+ db
+ "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?"
+ (Only groupMemberId)
+
+migrateMemberRelationsVector :: DB.Connection -> GroupMember -> IO ()
+migrateMemberRelationsVector db GroupMember {groupMemberId} =
+ migrateMemberRelationsVector' db groupMemberId
+
+migrateMemberRelationsVector' :: DB.Connection -> GroupMemberId -> IO ()
+migrateMemberRelationsVector' db groupMemberId = do
+ currentTs <- liftIO getCurrentTime
+ liftIO $ do
+#if defined(dbPostgres)
+ -- Lock the row first to ensure computation runs only after lock is acquired
+ _ :: [Only Int] <-
+ DB.query
+ db
+ "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NULL FOR UPDATE"
+ (Only groupMemberId)
+#endif
+ DB.execute
+ db
+ [sql|
+ UPDATE group_members
+ SET
+ member_relations_vector = (
+ SELECT migrate_relations_vector(idx, direction, intro_status)
+ FROM (
+ SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.to_group_member_id
+ WHERE i.re_group_member_id = group_members.group_member_id
+ UNION ALL
+ SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.re_group_member_id
+ WHERE i.to_group_member_id = group_members.group_member_id
+ ) AS relations
+ ),
+ updated_at = ?
+ WHERE group_member_id = ?
+ AND member_relations_vector IS NULL
+ |]
+ (currentTs, groupMemberId)
+
+getMemberRelationsVector_ :: DB.Connection -> GroupMember -> IO (Maybe ByteString)
+getMemberRelationsVector_ db GroupMember {groupMemberId} =
+ maybeFirstRow fromOnly $
+ DB.query
+ db
+ "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?"
+ (Only groupMemberId)
updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO ()
updateIntroStatus db introId introStatus = do
@@ -1595,9 +1810,9 @@ updateIntroStatus db introId introStatus = do
|]
(introStatus, currentTs, introId)
-getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro
-getIntroduction db reMember toMember = ExceptT $
- firstRow toIntro SEIntroNotFound $
+getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> IO (Maybe GroupMemberIntro)
+getIntroduction db reMember toMember =
+ maybeFirstRow toIntro $
DB.query
db
[sql|
@@ -1619,106 +1834,6 @@ getIntroducedGroupMemberIds db invitee =
"SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?"
(Only $ groupMemberId' invitee)
-getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember]
-getForwardIntroducedMembers db vr user invitee highlyAvailable = do
- memberIds <- map fromOnly <$> query
- rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
- where
- mId = groupMemberId' invitee
- query
- | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected)
- | otherwise =
- DB.query
- db
- (q <> " AND intro_chat_protocol_version >= ?")
- (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion)
- q =
- [sql|
- SELECT re_group_member_id
- FROM group_member_intros
- WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?)
- |]
-
--- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client,
--- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardIntroducedMembers)
-getForwardIntroducedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember]
-getForwardIntroducedModerators db vr user@User {userContactId} invitee = do
- memberIds <- map fromOnly <$> query
- rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
- where
- mId = groupMemberId' invitee
- query =
- DB.query
- db
- [sql|
- SELECT i.re_group_member_id
- FROM group_member_intros i
- JOIN group_members m ON m.group_member_id = i.re_group_member_id
- WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?)
- AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)
- |]
- (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner)
-
-getForwardInvitedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember]
-getForwardInvitedMembers db vr user forwardMember highlyAvailable = do
- memberIds <- map fromOnly <$> query
- rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
- where
- mId = groupMemberId' forwardMember
- query
- | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected)
- | otherwise =
- DB.query
- db
- (q <> " AND intro_chat_protocol_version >= ?")
- (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion)
- q =
- [sql|
- SELECT to_group_member_id
- FROM group_member_intros
- WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?)
- |]
-
--- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client,
--- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardInvitedMembers)
-getForwardInvitedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember]
-getForwardInvitedModerators db vr user@User {userContactId} forwardMember = do
- memberIds <- map fromOnly <$> query
- rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
- where
- mId = groupMemberId' forwardMember
- query =
- DB.query
- db
- [sql|
- SELECT i.to_group_member_id
- FROM group_member_intros i
- JOIN group_members m ON m.group_member_id = i.to_group_member_id
- WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?)
- AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)
- |]
- (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner)
-
-getForwardScopeMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMemberId -> IO (Maybe GroupMember)
-getForwardScopeMember db vr user GroupMember {groupMemberId = sendingGMId} scopeGMId = do
- (introExists_ :: Maybe Int64) <-
- liftIO $ maybeFirstRow fromOnly $
- DB.query
- db
- [sql|
- SELECT 1
- FROM group_member_intros
- WHERE
- (
- (re_group_member_id = ? AND to_group_member_id = ?) OR
- (re_group_member_id = ? AND to_group_member_id = ?)
- )
- AND intro_status NOT IN (?,?,?)
- LIMIT 1
- |]
- (sendingGMId, scopeGMId, scopeGMId, sendingGMId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected)
- pure introExists_ $>> (eitherToMaybe <$> runExceptT (getGroupMemberById db vr user scopeGMId))
-
createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember
createIntroReMember
db
@@ -2505,12 +2620,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g
db
[sql|
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown)
+ ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
:. (minV, maxV)
)
@@ -2608,6 +2723,25 @@ getUserGroupsToExpire db User {userId} globalTTL =
where
cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL"
+hasMembersWithoutVector :: DB.Connection -> IO Bool
+hasMembersWithoutVector db =
+ fromOnly . head
+ <$> DB.query_
+ db
+ "SELECT EXISTS (SELECT 1 FROM group_members WHERE member_relations_vector IS NULL LIMIT 1)"
+
+getGMsWithoutVectorIds :: DB.Connection -> IO [GroupMemberId]
+getGMsWithoutVectorIds db =
+ map fromOnly <$>
+ DB.query_
+ db
+ [sql|
+ SELECT group_member_id
+ FROM group_members
+ WHERE member_relations_vector IS NULL
+ LIMIT 1000
+ |]
+
updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo
updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do
updatedAt <- getCurrentTime
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs
index 9361713ea2..9dd388be0a 100644
--- a/src/Simplex/Chat/Store/Postgres/Migrations.hs
+++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs
@@ -22,6 +22,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connection
import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync
import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade
import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector
+-- import Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -44,6 +45,7 @@ schemaMigrations =
("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync),
("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade),
("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector)
+ -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2)
]
-- | The list of migrations in ascending order by date
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs
index 3f6deeb80f..33583cc076 100644
--- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs
+++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs
@@ -6,10 +6,91 @@ import Data.Text (Text)
import qualified Data.Text as T
import Text.RawString.QQ (r)
+-- This migration creates custom aggregate function migrate_relations_vector(idx, direction, intro_status).
+-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2).
+--
+-- Vector byte encoding: 4 reserved | 1 direction | 3 status
+-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced
+-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected
+--
+-- The aggregate transforms intro_status into relation status:
+-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1)
+-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3)
+-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2)
+-- - intro_status 'con': MRConnected (4)
+--
+-- Final byte combines direction and status: byte = (direction << 3) | status
+
m20251117_member_relations_vector :: Text
m20251117_member_relations_vector =
T.pack
[r|
+CREATE FUNCTION set_member_vector_new_relation(v BYTEA, idx BIGINT, direction INT, status INT)
+RETURNS BYTEA AS $$
+DECLARE
+ new_len INT;
+ result BYTEA;
+ byte_val INT;
+ old_byte INT;
+BEGIN
+ IF idx < 0 THEN
+ RETURN v;
+ END IF;
+ IF idx < length(v) THEN
+ old_byte := get_byte(v, idx::INT);
+ ELSE
+ old_byte := 0;
+ END IF;
+ byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status;
+ new_len := GREATEST(length(v), idx + 1);
+ IF new_len > length(v) THEN
+ result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v)));
+ ELSE
+ result := v;
+ END IF;
+ result := set_byte(result, idx::INT, byte_val);
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+CREATE FUNCTION migrate_relations_vector_step(state BYTEA, idx BIGINT, direction INT, intro_status TEXT)
+RETURNS BYTEA AS $$
+DECLARE
+ new_len INT;
+ result BYTEA;
+ status INT;
+ byte_val INT;
+BEGIN
+ IF idx < 0 THEN
+ RETURN state;
+ END IF;
+ IF intro_status = 're-con' THEN
+ IF direction = 0 THEN status := 2; ELSE status := 3; END IF;
+ ELSIF intro_status = 'to-con' THEN
+ IF direction = 0 THEN status := 3; ELSE status := 2; END IF;
+ ELSIF intro_status = 'con' THEN
+ status := 4;
+ ELSE
+ status := 1;
+ END IF;
+ byte_val := (direction * 8) + status;
+ new_len := GREATEST(length(state), idx + 1);
+ IF new_len > length(state) THEN
+ result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state)));
+ ELSE
+ result := state;
+ END IF;
+ result := set_byte(result, idx::INT, byte_val);
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+CREATE AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT) (
+ SFUNC = migrate_relations_vector_step,
+ STYPE = BYTEA,
+ INITCOND = ''
+);
+
ALTER TABLE group_members ADD COLUMN index_in_group BIGINT NOT NULL DEFAULT 0;
ALTER TABLE groups ADD COLUMN member_index BIGINT NOT NULL DEFAULT 0;
@@ -46,12 +127,28 @@ SET member_index = COALESCE((
FROM group_members
WHERE group_members.group_id = g.group_id
), 0);
+
+UPDATE group_members
+SET member_relations_vector = ''::BYTEA
+WHERE group_id IN (
+ SELECT mu.group_id
+ FROM group_members mu
+ WHERE mu.member_category = 'user'
+ AND (
+ mu.member_role NOT IN ('admin', 'owner')
+ OR mu.member_status IN ('removed', 'left', 'deleted')
+ )
+);
|]
down_m20251117_member_relations_vector :: Text
down_m20251117_member_relations_vector =
T.pack
[r|
+DROP AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT);
+DROP FUNCTION migrate_relations_vector_step(BYTEA, BIGINT, INT, TEXT);
+DROP FUNCTION set_member_vector_new_relation(BYTEA, BIGINT, INT, INT);
+
DROP INDEX idx_group_members_group_id_index_in_group;
ALTER TABLE group_members DROP COLUMN index_in_group;
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs
new file mode 100644
index 0000000000..7cfc273d62
--- /dev/null
+++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs
@@ -0,0 +1,45 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 where
+
+import Data.Text (Text)
+import qualified Data.Text as T
+import Text.RawString.QQ (r)
+
+-- Build member_relations_vector for all members that don't have it yet.
+-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector.
+--
+-- Query returns (idx, direction, intro_status) for each introduction:
+-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member
+-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it
+
+-- TODO [relations vector] drop group_member_intros in the end of migration
+m20251128_member_relations_vector_stage_2 :: Text
+m20251128_member_relations_vector_stage_2 =
+ T.pack
+ [r|
+UPDATE group_members
+SET member_relations_vector = (
+ SELECT migrate_relations_vector(idx, direction, intro_status)
+ FROM (
+ SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.to_group_member_id
+ WHERE i.re_group_member_id = group_members.group_member_id
+ UNION ALL
+ SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.re_group_member_id
+ WHERE i.to_group_member_id = group_members.group_member_id
+ ) AS relations
+)
+WHERE member_relations_vector IS NULL;
+|]
+
+-- TODO [relations vector] re-create group_member_intros
+down_m20251128_member_relations_vector_stage_2 :: Text
+down_m20251128_member_relations_vector_stage_2 =
+ T.pack
+ [r|
+
+|]
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql
index 712099d7c9..567aca0144 100644
--- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql
+++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql
@@ -34,6 +34,41 @@ $$;
+CREATE FUNCTION test_chat_schema.migrate_relations_vector_step(state bytea, idx bigint, direction integer, intro_status text) RETURNS bytea
+ LANGUAGE plpgsql IMMUTABLE
+ AS $$
+DECLARE
+ new_len INT;
+ result BYTEA;
+ status INT;
+ byte_val INT;
+BEGIN
+ IF idx < 0 THEN
+ RETURN state;
+ END IF;
+ IF intro_status = 're-con' THEN
+ IF direction = 0 THEN status := 2; ELSE status := 3; END IF;
+ ELSIF intro_status = 'to-con' THEN
+ IF direction = 0 THEN status := 3; ELSE status := 2; END IF;
+ ELSIF intro_status = 'con' THEN
+ status := 4;
+ ELSE
+ status := 1;
+ END IF;
+ byte_val := (direction * 8) + status;
+ new_len := GREATEST(length(state), idx + 1);
+ IF new_len > length(state) THEN
+ result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state)));
+ ELSE
+ result := state;
+ END IF;
+ result := set_byte(result, idx::INT, byte_val);
+ RETURN result;
+END;
+$$;
+
+
+
CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -85,6 +120,45 @@ END;
$$;
+
+CREATE FUNCTION test_chat_schema.set_member_vector_new_relation(v bytea, idx bigint, direction integer, status integer) RETURNS bytea
+ LANGUAGE plpgsql IMMUTABLE
+ AS $$
+DECLARE
+ new_len INT;
+ result BYTEA;
+ byte_val INT;
+ old_byte INT;
+BEGIN
+ IF idx < 0 THEN
+ RETURN v;
+ END IF;
+ IF idx < length(v) THEN
+ old_byte := get_byte(v, idx::INT);
+ ELSE
+ old_byte := 0;
+ END IF;
+ byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status;
+ new_len := GREATEST(length(v), idx + 1);
+ IF new_len > length(v) THEN
+ result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v)));
+ ELSE
+ result := v;
+ END IF;
+ result := set_byte(result, idx::INT, byte_val);
+ RETURN result;
+END;
+$$;
+
+
+
+CREATE AGGREGATE test_chat_schema.migrate_relations_vector(bigint, integer, text) (
+ SFUNC = test_chat_schema.migrate_relations_vector_step,
+ STYPE = bytea,
+ INITCOND = ''
+);
+
+
SET default_table_access_method = heap;
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs
index 13ada872b2..0358ae621d 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations.hs
+++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs
@@ -145,6 +145,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections
import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync
import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade
import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector
+-- import Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -290,6 +291,7 @@ schemaMigrations =
("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync),
("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade),
("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector)
+ -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2)
]
-- | The list of migrations in ascending order by date
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs
index bf5308bcfd..3e4b4157f0 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs
@@ -1,9 +1,77 @@
+{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector where
+import qualified Data.ByteString as B
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
+import Database.SQLite3 (funcArgBlob, funcArgInt64, funcArgText, funcResultBlob)
+import Database.SQLite3.Bindings
+import Foreign.C.Types
+import Foreign.Ptr
+import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), fromIntroDirInt, fromRelationInt, setNewRelation, setNewRelations)
+import Simplex.Messaging.Agent.Store.SQLite.Util (SQLiteFunc, SQLiteFuncFinal, mkSQLiteAggFinal, mkSQLiteAggStep, mkSQLiteFunc)
+
+-- This module defines custom aggregate function migrate_relations_vector(idx, direction, intro_status).
+-- It is passed via DBOpts and registered on DB open.
+-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2).
+--
+-- Vector byte encoding: 4 reserved | 1 direction | 3 status
+-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced
+-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected
+--
+-- The aggregate transforms intro_status into relation status:
+-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1)
+-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3)
+-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2)
+-- - intro_status 'con': MRConnected (4)
+--
+-- The final function builds the vector using setNewRelations.
+
+foreign export ccall "simplex_member_relations_step" sqliteMemberRelationsStep :: SQLiteFunc
+
+foreign import ccall "&simplex_member_relations_step" sqliteMemberRelationsStepPtr :: FunPtr SQLiteFunc
+
+foreign export ccall "simplex_member_relations_final" sqliteMemberRelationsFinal :: SQLiteFuncFinal
+
+foreign import ccall "&simplex_member_relations_final" sqliteMemberRelationsFinalPtr :: FunPtr SQLiteFuncFinal
+
+-- Step function for migrate_relations_vector aggregate.
+-- Accumulates (idx, direction, relation) tuples.
+sqliteMemberRelationsStep :: SQLiteFunc
+sqliteMemberRelationsStep = mkSQLiteAggStep [] $ \_ args acc -> do
+ idx <- funcArgInt64 args 0
+ direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 1
+ introStatus <- funcArgText args 2
+ let relation = introStatusToRelation direction introStatus
+ pure $ (idx, (direction, relation)) : acc
+ where
+ introStatusToRelation dir status = case status of
+ "re-con" -> if dir == IDSubjectIntroduced then MRSubjectConnected else MRReferencedConnected
+ "to-con" -> if dir == IDSubjectIntroduced then MRReferencedConnected else MRSubjectConnected
+ "con" -> MRConnected
+ _ -> MRIntroduced -- 'new', 'sent', 'rcv', 'fwd'
+
+-- Final function for migrate_relations_vector aggregate.
+-- Builds the vector from accumulated tuples using setNewRelations.
+sqliteMemberRelationsFinal :: SQLiteFuncFinal
+sqliteMemberRelationsFinal = mkSQLiteAggFinal [] $ \cxt acc -> funcResultBlob cxt $ setNewRelations acc B.empty
+
+-- Non-aggregate function set_member_vector_new_relation(vector, idx, direction, status).
+-- Sets a new relation in the vector and returns the updated vector.
+
+foreign export ccall "simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelation :: SQLiteFunc
+
+foreign import ccall "&simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelationPtr :: FunPtr SQLiteFunc
+
+sqliteSetMemberVectorNewRelation :: SQLiteFunc
+sqliteSetMemberVectorNewRelation = mkSQLiteFunc $ \cxt args -> do
+ v <- funcArgBlob args 0
+ idx <- funcArgInt64 args 1
+ direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 2
+ status <- fromRelationInt . fromIntegral <$> funcArgInt64 args 3
+ funcResultBlob cxt $ setNewRelation idx direction status v
m20251117_member_relations_vector :: Query
m20251117_member_relations_vector =
@@ -46,6 +114,18 @@ SET member_index = COALESCE((
FROM group_members
WHERE group_members.group_id = g.group_id
), 0);
+
+UPDATE group_members
+SET member_relations_vector = x''
+WHERE group_id IN (
+ SELECT mu.group_id
+ FROM group_members mu
+ WHERE mu.member_category = 'user'
+ AND (
+ mu.member_role NOT IN (CAST('admin' AS BLOB), CAST('owner' AS BLOB))
+ OR mu.member_status IN ('removed', 'left', 'deleted')
+ )
+);
|]
down_m20251117_member_relations_vector :: Query
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs
new file mode 100644
index 0000000000..f510a50410
--- /dev/null
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+-- Build member_relations_vector for all members that don't have it yet.
+-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector.
+--
+-- Query returns (idx, direction, intro_status) for each introduction:
+-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member
+-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it
+
+-- TODO [relations vector] drop group_member_intros in the end of migration
+m20251128_member_relations_vector_stage_2 :: Query
+m20251128_member_relations_vector_stage_2 =
+ [sql|
+UPDATE group_members
+SET member_relations_vector = (
+ SELECT migrate_relations_vector(idx, direction, intro_status)
+ FROM (
+ SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.to_group_member_id
+ WHERE i.re_group_member_id = group_members.group_member_id
+ UNION ALL
+ SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.re_group_member_id
+ WHERE i.to_group_member_id = group_members.group_member_id
+ )
+)
+WHERE member_relations_vector IS NULL;
+|]
+
+-- TODO [relations vector] re-create group_member_intros
+down_m20251128_member_relations_vector_stage_2 :: Query
+down_m20251128_member_relations_vector_stage_2 =
+ [sql|
+
+|]
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
index 9156b56fcb..c46f6a5e7d 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
@@ -24,10 +24,10 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?)
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -76,10 +76,10 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -260,9 +260,9 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -293,10 +293,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -327,10 +327,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -492,9 +492,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?)
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -525,10 +525,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -989,19 +989,12 @@ Query:
Plan:
-Query:
- INSERT INTO group_member_intros
- (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at)
- VALUES (?,?,?,?,?,?)
-
-Plan:
-
Query:
INSERT INTO group_members
- (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id,
+ (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -1191,24 +1184,6 @@ SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_con
SEARCH ct USING INTEGER PRIMARY KEY (rowid=?)
SEARCH cp USING INTEGER PRIMARY KEY (rowid=?)
-Query:
- SELECT 1
- FROM group_member_intros
- WHERE
- (
- (re_group_member_id = ? AND to_group_member_id = ?) OR
- (re_group_member_id = ? AND to_group_member_id = ?)
- )
- AND intro_status NOT IN (?,?,?)
- LIMIT 1
-
-Plan:
-MULTI-INDEX OR
-INDEX 1
-SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?)
-INDEX 2
-SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?)
-
Query:
SELECT 1 FROM users
WHERE (user_id = ? AND local_display_name = ?)
@@ -1455,28 +1430,6 @@ Plan:
SEARCH g USING INTEGER PRIMARY KEY (rowid=?)
SEARCH i USING INTEGER PRIMARY KEY (rowid=?)
-Query:
- SELECT i.re_group_member_id
- FROM group_member_intros i
- JOIN group_members m ON m.group_member_id = i.re_group_member_id
- WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?)
- AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)
-
-Plan:
-SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?)
-SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
-
-Query:
- SELECT i.to_group_member_id
- FROM group_member_intros i
- JOIN group_members m ON m.group_member_id = i.to_group_member_id
- WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?)
- AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)
-
-Plan:
-SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?)
-SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
-
Query:
SELECT member_status
FROM group_members
@@ -1645,10 +1598,10 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?)
Query:
INSERT INTO group_members
- ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by,
+ ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?)
@@ -3509,6 +3462,14 @@ Plan:
SEARCH f USING INTEGER PRIMARY KEY (rowid=?)
SEARCH i USING INTEGER PRIMARY KEY (rowid=?)
+Query:
+ SELECT index_in_group, member_relations_vector
+ FROM group_members
+ WHERE local_display_name = ?
+
+Plan:
+SCAN group_members
+
Query:
SELECT m.group_member_id
FROM group_members m
@@ -3559,14 +3520,6 @@ SEARCH r USING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?)
SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) LEFT-JOIN
SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
-Query:
- SELECT re_group_member_id
- FROM group_member_intros
- WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?)
- AND intro_chat_protocol_version >= ?
-Plan:
-SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?)
-
Query:
SELECT reaction
FROM chat_item_reactions
@@ -3655,14 +3608,6 @@ Query:
Plan:
SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?)
-Query:
- SELECT to_group_member_id
- FROM group_member_intros
- WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?)
- AND intro_chat_protocol_version >= ?
-Plan:
-SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?)
-
Query:
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
FROM usage_conditions
@@ -3782,6 +3727,40 @@ SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_
LIST SUBQUERY 2
SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name
+Query:
+ UPDATE group_members
+ SET
+ member_relations_vector = (
+ SELECT migrate_relations_vector(idx, direction, intro_status)
+ FROM (
+ SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.to_group_member_id
+ WHERE i.re_group_member_id = group_members.group_member_id
+ UNION ALL
+ SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status
+ FROM group_member_intros i
+ JOIN group_members m ON m.group_member_id = i.re_group_member_id
+ WHERE i.to_group_member_id = group_members.group_member_id
+ ) AS relations
+ ),
+ updated_at = ?
+ WHERE group_member_id = ?
+ AND member_relations_vector IS NULL
+
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+CORRELATED SCALAR SUBQUERY 3
+CO-ROUTINE relations
+COMPOUND QUERY
+LEFT-MOST SUBQUERY
+SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?)
+SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
+UNION ALL
+SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?)
+SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
+SCAN relations
+
Query:
UPDATE group_members
SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ?
@@ -3790,6 +3769,14 @@ Query:
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+Query:
+ UPDATE group_members
+ SET member_relations_vector = ?
+ WHERE local_display_name = ?
+
+Plan:
+SCAN group_members
+
Query:
UPDATE groups
SET member_index = member_index + 1
@@ -4739,12 +4726,12 @@ Plan:
SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?)
Query:
- UPDATE group_member_intros
- SET intro_status = ?, updated_at = ?
- WHERE group_member_intro_id = ?
+ UPDATE group_members
+ SET member_relations_vector = ?, updated_at = ?
+ WHERE group_member_id = ?
Plan:
-SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE group_members
@@ -5133,6 +5120,44 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
+Query:
+ SELECT
+ m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
+ m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
+ m.created_at, m.updated_at,
+ m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts,
+ c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
+ c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
+ c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
+ c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version
+ FROM group_members m
+ JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
+ LEFT JOIN connections c ON c.group_member_id = m.group_member_id
+ WHERE m.group_id = ? AND m.index_in_group = ?
+Plan:
+SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?)
+SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
+
+Query:
+ SELECT
+ m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
+ m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
+ m.created_at, m.updated_at,
+ m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts,
+ c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
+ c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
+ c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
+ c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version
+ FROM group_members m
+ JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
+ LEFT JOIN connections c ON c.group_member_id = m.group_member_id
+ WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)
+Plan:
+SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?)
+SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
+
Query:
SELECT
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
@@ -5209,25 +5234,6 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
-Query:
- SELECT
- m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
- m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
- m.created_at, m.updated_at,
- m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts,
- c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
- c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
- c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
- c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version
- FROM group_members m
- JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
- LEFT JOIN connections c ON c.group_member_id = m.group_member_id
- WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ?
-Plan:
-SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group)
-SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
-SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
-
Query:
SELECT
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
@@ -6083,6 +6089,10 @@ Plan:
Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?)
Plan:
+Query: SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+
Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1
Plan:
SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?)
@@ -6113,6 +6123,12 @@ SCAN CONSTANT ROW
SCALAR SUBQUERY 1
SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
+Query: SELECT EXISTS (SELECT 1 FROM group_members WHERE member_relations_vector IS NULL LIMIT 1)
+Plan:
+SCAN CONSTANT ROW
+SCALAR SUBQUERY 1
+SCAN group_members
+
Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ?
Plan:
SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?)
@@ -6281,6 +6297,14 @@ Query: SELECT max(active_order) FROM users
Plan:
SEARCH users
+Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = ?
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+
+Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+
Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -6297,10 +6321,6 @@ Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connectio
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
-Query: SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?
-Plan:
-SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?)
-
Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -6521,6 +6541,10 @@ Query: UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE grou
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ?
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+
Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs
index 602ea8236e..15ec3ec49d 100644
--- a/src/Simplex/Chat/Store/Shared.hs
+++ b/src/Simplex/Chat/Store/Shared.hs
@@ -93,9 +93,12 @@ data StoreError
| SEGroupNotFoundByName {groupName :: GroupName}
| SEGroupMemberNameNotFound {groupId :: GroupId, groupMemberName :: ContactName}
| SEGroupMemberNotFound {groupMemberId :: GroupMemberId}
+ | SEGroupMemberNotFoundByIndex {groupMemberIndex :: Int64}
+ | SEMemberRelationsVectorNotFound {groupMemberId :: GroupMemberId}
| SEGroupHostMemberNotFound {groupId :: GroupId}
| SEGroupMemberNotFoundByMemberId {memberId :: MemberId}
| SEMemberContactGroupMemberNotFound {contactId :: ContactId}
+ | SEInvalidMemberRelationUpdate
| SEGroupWithoutUser
| SEDuplicateGroupMember
| SEGroupAlreadyJoined
@@ -119,7 +122,6 @@ data StoreError
| SEConnectionNotFoundById {connId :: Int64}
| SEConnectionNotFoundByMemberId {groupMemberId :: GroupMemberId}
| SEPendingConnectionNotFound {connId :: Int64}
- | SEIntroNotFound
| SEUniqueID
| SELargeMsg
| SEInternalError {message :: String}
diff --git a/src/Simplex/Chat/Types/MemberRelations.hs b/src/Simplex/Chat/Types/MemberRelations.hs
index 7245b22472..6e0eb379d7 100644
--- a/src/Simplex/Chat/Types/MemberRelations.hs
+++ b/src/Simplex/Chat/Types/MemberRelations.hs
@@ -1,15 +1,25 @@
{-# LANGUAGE LambdaCase #-}
module Simplex.Chat.Types.MemberRelations
- ( MemberRelation (..),
+ ( IntroductionDirection (..),
+ MemberRelation (..),
+ toIntroDirInt,
+ fromIntroDirInt,
+ toRelationInt,
+ fromRelationInt,
getRelation,
+ getRelation',
+ getRelationsIndexes,
setRelation,
setRelations,
+ setRelationConnected,
+ setNewRelation,
+ setNewRelations,
)
where
import Control.Monad
-import Data.Bits ((.&.), (.|.), complement)
+import Data.Bits (shiftL, shiftR, (.&.), (.|.), complement)
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.ByteString.Internal (toForeignPtr, unsafeCreate)
@@ -20,55 +30,129 @@ import Foreign.Marshal.Utils (copyBytes, fillBytes)
import Foreign.Ptr (plusPtr)
import Foreign.Storable (peekByteOff, pokeByteOff)
+data IntroductionDirection
+ = IDSubjectIntroduced -- Member described by vector (subject member, vector "owner") is introduced to member referenced in vector
+ | IDReferencedIntroduced -- Member referenced in vector is introduced to subject member
+ deriving (Eq, Show)
+
+toIntroDirInt :: IntroductionDirection -> Word8
+toIntroDirInt = \case
+ IDSubjectIntroduced -> 0
+ IDReferencedIntroduced -> 1
+
+fromIntroDirInt :: Word8 -> IntroductionDirection
+fromIntroDirInt = \case
+ 0 -> IDSubjectIntroduced
+ 1 -> IDReferencedIntroduced
+ _ -> IDSubjectIntroduced
+
data MemberRelation
= MRNew
| MRIntroduced
- | MRConnected
- deriving (Eq, Show)
+ | MRSubjectConnected -- Subject member notified about connection to referenced member
+ | MRReferencedConnected -- Referenced member notified about connection to subject member
+ | MRConnected -- Both members notified about connection
+ deriving (Eq, Ord, Show)
toRelationInt :: MemberRelation -> Word8
toRelationInt = \case
MRNew -> 0
MRIntroduced -> 1
- MRConnected -> 2
+ MRSubjectConnected -> 2
+ MRReferencedConnected -> 3
+ MRConnected -> 4
fromRelationInt :: Word8 -> MemberRelation
fromRelationInt = \case
0 -> MRNew
1 -> MRIntroduced
- 2 -> MRConnected
+ 2 -> MRSubjectConnected
+ 3 -> MRReferencedConnected
+ 4 -> MRConnected
_ -> MRNew
+-- Bit layout: 4 reserved | 1 direction | 3 status
+
-- | Get the relation status of a member at a given index from the relations vector.
-- Returns 'MRNew' if the vector is not long enough (lazy initialization).
getRelation :: Int64 -> ByteString -> MemberRelation
-getRelation i v
- | i < 0 || fromIntegral i >= B.length v = MRNew
- | otherwise = fromRelationInt $ (v `B.index` fromIntegral i) .&. relationMask
+getRelation i v = snd $ getRelation' i v
+-- | Get both direction and status of a member at a given index from the relations vector.
+-- Returns (IDSubjectIntroduced, MRNew) if the vector is not long enough (lazy initialization).
+getRelation' :: Int64 -> ByteString -> (IntroductionDirection, MemberRelation)
+getRelation' i v
+ | i < 0 || fromIntegral i >= B.length v = (IDSubjectIntroduced, MRNew)
+ | otherwise =
+ let b = v `B.index` fromIntegral i
+ in (fromIntroDirInt $ (b .&. directionMask) `shiftR` 3, fromRelationInt $ b .&. statusMask)
+
+-- | Get the indexes of members with the given relation status from the relations vector.
+getRelationsIndexes :: MemberRelation -> ByteString -> [Int64]
+getRelationsIndexes r v = [i | i <- [0 .. fromIntegral (B.length v) - 1], getRelation i v == r]
-- | Set the relation status of a member at a given index in the relations vector.
--- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation).
+-- Preserves the introduction direction. Expands the vector lazily if needed.
setRelation :: Int64 -> MemberRelation -> ByteString -> ByteString
setRelation i r v
| i >= 0 = setRelations [(i, r)] v
| otherwise = v
--- | Set multiple relations at once.
--- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation).
+-- | Set multiple relation statuses at once.
+-- Preserves the introduction direction. Expands the vector lazily if needed.
setRelations :: [(Int64, MemberRelation)] -> ByteString -> ByteString
-setRelations [] v = v
-setRelations relations v =
+setRelations = setRelations_ $ \r b -> (b .&. complement statusMask) .|. toRelationInt r
+
+-- | Set relation to connected state based on passed status and current status.
+-- newStatus should be MRSubjectConnected or MRReferencedConnected, otherwise returns vector unchanged.
+-- Logic:
+-- - if newStatus is complementary to oldStatus -> set MRConnected
+-- - if newStatus > oldStatus (by enum order) -> set newStatus
+-- - otherwise don't update
+setRelationConnected :: Int64 -> MemberRelation -> ByteString -> ByteString
+setRelationConnected i newStatus v
+ | newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected = v
+ | otherwise = case status' of
+ Nothing -> v
+ Just s -> setRelation i s v
+ where
+ oldStatus = getRelation i v
+ status' = case (oldStatus, newStatus) of
+ -- complementary statuses -> MRConnected
+ (MRSubjectConnected, MRReferencedConnected) -> Just MRConnected
+ (MRReferencedConnected, MRSubjectConnected) -> Just MRConnected
+ -- newStatus > oldStatus -> set newStatus
+ _ | newStatus > oldStatus -> Just newStatus
+ | otherwise -> Nothing
+
+-- | Set a new relation with both direction and status at a given index.
+-- Expands the vector lazily if needed.
+setNewRelation :: Int64 -> IntroductionDirection -> MemberRelation -> ByteString -> ByteString
+setNewRelation i dir r v
+ | i >= 0 = setNewRelations [(i, (dir, r))] v
+ | otherwise = v
+
+-- | Set multiple new relations with both direction and status at once.
+-- Expands the vector lazily if needed.
+setNewRelations :: [(Int64, (IntroductionDirection, MemberRelation))] -> ByteString -> ByteString
+setNewRelations = setRelations_ $ \(dir, r) b -> (b .&. relationMask) .|. (toIntroDirInt dir `shiftL` 3) .|. toRelationInt r
+ where
+ relationMask = complement (statusMask .|. directionMask)
+
+setRelations_ :: (r -> Word8 -> Word8) -> [(Int64, r)] -> ByteString -> ByteString
+setRelations_ _ [] v = v
+setRelations_ updateByte relations v =
let (fp, off, len) = toForeignPtr v
newLen = max len $ fromIntegral $ maximum (map fst relations) + 1
in unsafeCreate newLen $ \ptr -> do
withForeignPtr fp $ \vPtr -> copyBytes ptr (vPtr `plusPtr` off) len
when (newLen > len) $ fillBytes (ptr `plusPtr` len) 0 (newLen - len)
- forM_ relations $ \(ix, r) -> when (ix >= 0) $ do
+ forM_ relations $ \(ix, r) -> when (ix >= 0) $
let i = fromIntegral ix
- b <- peekByteOff ptr i
- let b' = (b .&. complement relationMask) .|. toRelationInt r
- pokeByteOff ptr i b'
+ in pokeByteOff ptr i . updateByte r =<< peekByteOff ptr i
-relationMask :: Word8
-relationMask = 0x07 -- reserving 3 bits
+statusMask :: Word8
+statusMask = 0x07 -- bits 0-2
+
+directionMask :: Word8
+directionMask = 0x08 -- bit 3
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index c16f76148f..f4a995adfe 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -24,7 +24,6 @@ import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, sortOn)
import Data.List.NonEmpty (NonEmpty (..))
-import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index 4c5efa26b6..42562d8e16 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -18,6 +18,7 @@ import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Monad (forM_, void, when)
import Data.Bifunctor (second)
+import Data.Maybe (fromMaybe)
import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.List (intercalate, isInfixOf)
@@ -30,10 +31,12 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId)
import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText)
import Simplex.Chat.Types
+import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation)
import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..))
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.RetryInterval
import qualified Simplex.Messaging.Agent.Store.DB as DB
+import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Server.Env.STM hiding (subscriptions)
import Simplex.Messaging.Transport
@@ -1769,6 +1772,7 @@ testGroupDelayedModeration ps = do
-- and forwarding client doesn't check compatibility)
void $ withCCTransaction alice $ \db ->
DB.execute_ db "UPDATE group_member_intros SET intro_status='con'"
+ updateGroupForwardingVectors alice "bob" "cath" MRConnected
cath #> "#team hi" -- message is pending for bob
alice <# "#team cath> hi"
@@ -1815,6 +1819,7 @@ testGroupDelayedModerationFullDelete ps = do
-- and forwarding client doesn't check compatibility)
void $ withCCTransaction alice $ \db ->
DB.execute_ db "UPDATE group_member_intros SET intro_status='con'"
+ updateGroupForwardingVectors alice "bob" "cath" MRConnected
cath #> "#team hi" -- message is pending for bob
alice <# "#team cath> hi"
@@ -5018,7 +5023,7 @@ testGroupMsgForwardReport =
setupGroupForwarding :: TestCC -> TestCC -> TestCC -> IO ()
setupGroupForwarding host invitee1 invitee2 = do
- threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected
+ threadDelay 1000000 -- delay so member relations don't get overwritten to connected
invitee1Name <- userName invitee1
invitee2Name <- userName invitee2
@@ -5050,15 +5055,60 @@ setupGroupForwarding host invitee1 invitee2 = do
|]
(invitee1Name, invitee2Name)
+ setupGroupForwardingVectors host invitee1 invitee2
+
+setupGroupForwardingVectors :: TestCC -> TestCC -> TestCC -> IO ()
+setupGroupForwardingVectors host invitee1 invitee2 = do
+ invitee1Name <- userName invitee1
+ invitee2Name <- userName invitee2
+ updateGroupForwardingVectors host invitee1Name invitee2Name MRIntroduced
+
+updateGroupForwardingVectors :: TestCC -> String -> String -> MemberRelation -> IO ()
+updateGroupForwardingVectors host invitee1Name invitee2Name relation = do
+ void $ withCCTransaction host $ \db -> do
+ [(invitee1Index, invitee1Vec)] <- DB.query db
+ [sql|
+ SELECT index_in_group, member_relations_vector
+ FROM group_members
+ WHERE local_display_name = ?
+ |]
+ (Only invitee1Name)
+ [(invitee2Index, invitee2Vec)] <- DB.query db
+ [sql|
+ SELECT index_in_group, member_relations_vector
+ FROM group_members
+ WHERE local_display_name = ?
+ |]
+ (Only invitee2Name)
+
+ let invitee1Vec' = setRelation invitee2Index relation (fromMaybe B.empty invitee1Vec)
+ DB.execute db
+ [sql|
+ UPDATE group_members
+ SET member_relations_vector = ?
+ WHERE local_display_name = ?
+ |]
+ (Binary invitee1Vec', invitee1Name)
+
+ let invitee2Vec' = setRelation invitee1Index relation (fromMaybe B.empty invitee2Vec)
+ DB.execute db
+ [sql|
+ UPDATE group_members
+ SET member_relations_vector = ?
+ WHERE local_display_name = ?
+ |]
+ (Binary invitee2Vec', invitee2Name)
+
testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO ()
testGroupMsgForwardDeduplicate =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
- threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected
+ threadDelay 1000000 -- delay so member relations don't get overwritten to connected
void $ withCCTransaction alice $ \db ->
DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'"
+ setupGroupForwardingVectors alice bob cath
bob #> "#team hi there"
alice <# "#team bob> hi there"
diff --git a/tests/MemberRelationsTests.hs b/tests/MemberRelationsTests.hs
index 968dcbec43..c1afbd7f6a 100644
--- a/tests/MemberRelationsTests.hs
+++ b/tests/MemberRelationsTests.hs
@@ -29,21 +29,23 @@ memberRelationsTests = do
getRelation 0 vec `shouldBe` MRIntroduced
it "reads multiple relations" $ do
- let vec = B.pack [0, 0, 1, 2]
+ let vec = B.pack [0, 0, 1, 2, 3, 4]
getRelation 0 vec `shouldBe` MRNew
getRelation 1 vec `shouldBe` MRNew
getRelation 2 vec `shouldBe` MRIntroduced
- getRelation 3 vec `shouldBe` MRConnected
+ getRelation 3 vec `shouldBe` MRSubjectConnected
+ getRelation 4 vec `shouldBe` MRReferencedConnected
+ getRelation 5 vec `shouldBe` MRConnected
it "reads multiple relations 2" $ do
let vec = B.pack [1, 1, 0, 0, 2, 2, 0, 0]
getRelation 0 vec `shouldBe` MRIntroduced
getRelation 1 vec `shouldBe` MRIntroduced
- getRelation 4 vec `shouldBe` MRConnected
- getRelation 5 vec `shouldBe` MRConnected
+ getRelation 4 vec `shouldBe` MRSubjectConnected
+ getRelation 5 vec `shouldBe` MRSubjectConnected
it "ignore reserved bits" $ do
- let vec = B.pack [0xF9] -- 11111001
+ let vec = B.pack [0xF1] -- reserved=1111, direction=0, status=001
getRelation 0 vec `shouldBe` MRIntroduced
describe "setRelation" $ do
@@ -56,9 +58,9 @@ memberRelationsTests = do
vec `shouldBe` B.empty
it "expands vector to required length" $ do
- let vec = setRelation 5 MRConnected B.empty
+ let vec = setRelation 5 MRSubjectConnected B.empty
B.length vec `shouldBe` 6
- getRelation 5 vec `shouldBe` MRConnected
+ getRelation 5 vec `shouldBe` MRSubjectConnected
-- Other positions should be MRNew (0)
getRelation 0 vec `shouldBe` MRNew
getRelation 10 vec `shouldBe` MRNew
@@ -69,21 +71,21 @@ memberRelationsTests = do
let vec1 = setRelation 0 MRIntroduced B.empty
let vec2 = setRelation 1 MRIntroduced vec1
-- Update: [01][10][00][00]
- let vec3 = setRelation 1 MRConnected vec2
+ let vec3 = setRelation 1 MRSubjectConnected vec2
getRelation 0 vec3 `shouldBe` MRIntroduced
- getRelation 1 vec3 `shouldBe` MRConnected
+ getRelation 1 vec3 `shouldBe` MRSubjectConnected
it "updates relation in specific byte of multi-byte vector" $ do
let vec1 = setRelation 0 MRIntroduced B.empty
- let vec2 = setRelation 10 MRConnected vec1
+ let vec2 = setRelation 10 MRSubjectConnected vec1
B.length vec2 `shouldBe` 11
getRelation 0 vec2 `shouldBe` MRIntroduced
- getRelation 10 vec2 `shouldBe` MRConnected
+ getRelation 10 vec2 `shouldBe` MRSubjectConnected
forM_ [1..9] $ \i -> getRelation i vec2 `shouldBe` MRNew
it "handles setting relation at last position in byte" $ do
- let vec = setRelation 3 MRConnected B.empty
- getRelation 3 vec `shouldBe` MRConnected
+ let vec = setRelation 3 MRSubjectConnected B.empty
+ getRelation 3 vec `shouldBe` MRSubjectConnected
it "preserves vector when setting same value" $ do
let vec1 = setRelation 0 MRIntroduced B.empty
@@ -91,12 +93,24 @@ memberRelationsTests = do
vec2 `shouldBe` vec1
getRelation 0 vec2 `shouldBe` MRIntroduced
- it "preserves reserved bits" $ do
- let v = B.pack [0xF8] -- 11111000
+ it "preserves reserved bits and direction" $ do
+ let v = B.pack [0xF8] -- reserved=1111, direction=1, status=000
getRelation 0 v `shouldBe` MRNew
- let v' = setRelation 0 MRIntroduced v
- getRelation 0 v' `shouldBe` MRIntroduced
- B.unpack v' `shouldBe` [0xF9] -- 11111001
+ let v' = setRelation 0 MRConnected v
+ getRelation 0 v' `shouldBe` MRConnected
+ B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100
+
+ describe "setNewRelation" $ do
+ it "sets new relation with direction" $ do
+ let vec = setNewRelation 0 IDReferencedIntroduced MRSubjectConnected B.empty
+ getRelation' 0 vec `shouldBe` (IDReferencedIntroduced, MRSubjectConnected)
+ B.unpack vec `shouldBe` [0x0A] -- direction=1, status=010
+
+ it "preserves reserved bits" $ do
+ let v = B.pack [0xF0] -- reserved=1111, direction=0, status=000
+ let v' = setNewRelation 0 IDReferencedIntroduced MRConnected v
+ getRelation 0 v' `shouldBe` MRConnected
+ B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100
describe "setRelations" $ do
it "returns same vector for empty list" $ do
@@ -104,106 +118,183 @@ memberRelationsTests = do
setRelations [] vec `shouldBe` vec
it "sets multiple relations in empty vector" $ do
- let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRReferencedConnected), (3, MRConnected)]
let vec = setRelations updates B.empty
getRelation 0 vec `shouldBe` MRIntroduced
- getRelation 1 vec `shouldBe` MRConnected
- getRelation 2 vec `shouldBe` MRIntroduced
- getRelation 3 vec `shouldBe` MRNew -- Unset position
+ getRelation 1 vec `shouldBe` MRSubjectConnected
+ getRelation 2 vec `shouldBe` MRReferencedConnected
+ getRelation 3 vec `shouldBe` MRConnected
+ getRelation 4 vec `shouldBe` MRNew -- Unset position
it "sets multiple relations 1" $ do
- let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRConnected), (3, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRSubjectConnected), (3, MRIntroduced)]
let vec = setRelations updates B.empty
B.length vec `shouldBe` 4
getRelation 0 vec `shouldBe` MRIntroduced
- getRelation 1 vec `shouldBe` MRConnected
- getRelation 2 vec `shouldBe` MRConnected
+ getRelation 1 vec `shouldBe` MRSubjectConnected
+ getRelation 2 vec `shouldBe` MRSubjectConnected
getRelation 3 vec `shouldBe` MRIntroduced
it "sets multiple relations 2" $ do
- let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRIntroduced)]
let vec = setRelations updates B.empty
B.length vec `shouldBe` 11
getRelation 0 vec `shouldBe` MRIntroduced
- getRelation 5 vec `shouldBe` MRConnected
+ getRelation 5 vec `shouldBe` MRSubjectConnected
getRelation 10 vec `shouldBe` MRIntroduced
getRelation 7 vec `shouldBe` MRNew -- Unset position between
it "handles sparse updates (few indices in large range)" $ do
-- Sparse: 3 updates in large group
- let updates = [(0, MRIntroduced), (100, MRConnected), (5000, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (100, MRSubjectConnected), (5000, MRIntroduced)]
let vec = setRelations updates B.empty
getRelation 0 vec `shouldBe` MRIntroduced
- getRelation 100 vec `shouldBe` MRConnected
+ getRelation 100 vec `shouldBe` MRSubjectConnected
getRelation 5000 vec `shouldBe` MRIntroduced
getRelation 50 vec `shouldBe` MRNew -- Untouched position
it "handles dense updates (many consecutive indices)" $ do
-- Dense: many consecutive updates
- let updates = [(i, if even i then MRIntroduced else MRConnected) | i <- [0 .. 99]]
+ let updates = [(i, if even i then MRIntroduced else MRSubjectConnected) | i <- [0 .. 99]]
let vec = setRelations updates B.empty
- all (\i -> getRelation i vec == (if even i then MRIntroduced else MRConnected)) [0 .. 99] `shouldBe` True
+ all (\i -> getRelation i vec == (if even i then MRIntroduced else MRSubjectConnected)) [0 .. 99] `shouldBe` True
it "handles unsorted input correctly" $ do
- let updates = [(10, MRConnected), (2, MRIntroduced), (5, MRConnected), (0, MRIntroduced)]
+ let updates = [(10, MRSubjectConnected), (2, MRIntroduced), (5, MRSubjectConnected), (0, MRIntroduced)]
let vec = setRelations updates B.empty
getRelation 0 vec `shouldBe` MRIntroduced
getRelation 2 vec `shouldBe` MRIntroduced
- getRelation 5 vec `shouldBe` MRConnected
- getRelation 10 vec `shouldBe` MRConnected
+ getRelation 5 vec `shouldBe` MRSubjectConnected
+ getRelation 10 vec `shouldBe` MRSubjectConnected
it "handles duplicate indices (last one wins)" $ do
- let updates = [(0, MRIntroduced), (0, MRConnected), (0, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (0, MRSubjectConnected), (0, MRIntroduced)]
let vec = setRelations updates B.empty
getRelation 0 vec `shouldBe` MRIntroduced
it "preserves existing relations not in update list" $ do
- let vec1 = setRelation 0 MRConnected B.empty
+ let vec1 = setRelation 0 MRSubjectConnected B.empty
let vec2 = setRelation 5 MRIntroduced vec1
- let updates = [(10, MRConnected)]
+ let updates = [(10, MRSubjectConnected)]
let vec3 = setRelations updates vec2
- getRelation 0 vec3 `shouldBe` MRConnected
+ getRelation 0 vec3 `shouldBe` MRSubjectConnected
getRelation 5 vec3 `shouldBe` MRIntroduced
- getRelation 10 vec3 `shouldBe` MRConnected
+ getRelation 10 vec3 `shouldBe` MRSubjectConnected
+
+ describe "setNewRelations" $ do
+ it "sets multiple new relations with direction" $ do
+ let updates = [(0, (IDSubjectIntroduced, MRIntroduced)), (1, (IDReferencedIntroduced, MRSubjectConnected))]
+ let vec = setNewRelations updates B.empty
+ getRelation 0 vec `shouldBe` MRIntroduced
+ getRelation 1 vec `shouldBe` MRSubjectConnected
+ B.unpack vec `shouldBe` [0x01, 0x0A] -- [dir=0,status=001], [dir=1,status=010]
describe "edge cases and invariants" $ do
it "round-trip: set then get returns same value" $ do
- let vec1 = setRelation 42 MRConnected B.empty
- getRelation 42 vec1 `shouldBe` MRConnected
+ let vec1 = setRelation 42 MRSubjectConnected B.empty
+ getRelation 42 vec1 `shouldBe` MRSubjectConnected
it "multiple round-trips preserve values" $ do
let vec1 = setRelation 0 MRIntroduced B.empty
- let vec2 = setRelation 1 MRConnected vec1
- let vec3 = setRelation 2 MRIntroduced vec2
- getRelation 0 vec3 `shouldBe` MRIntroduced
- getRelation 1 vec3 `shouldBe` MRConnected
- getRelation 2 vec3 `shouldBe` MRIntroduced
+ let vec2 = setRelation 1 MRSubjectConnected vec1
+ let vec3 = setRelation 2 MRReferencedConnected vec2
+ let vec4 = setRelation 3 MRConnected vec3
+ getRelation 0 vec4 `shouldBe` MRIntroduced
+ getRelation 1 vec4 `shouldBe` MRSubjectConnected
+ getRelation 2 vec4 `shouldBe` MRReferencedConnected
+ getRelation 3 vec4 `shouldBe` MRConnected
it "setRelations equivalent to multiple setRelation calls" $ do
- let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRConnected)]
let vecBatch = setRelations updates B.empty
- let vecSeq = setRelation 10 MRIntroduced $ setRelation 5 MRConnected $ setRelation 0 MRIntroduced B.empty
+ let vecSeq = setRelation 10 MRConnected $ setRelation 5 MRSubjectConnected $ setRelation 0 MRIntroduced B.empty
vecBatch `shouldBe` vecSeq
getRelation 0 vecBatch `shouldBe` getRelation 0 vecSeq
getRelation 5 vecBatch `shouldBe` getRelation 5 vecSeq
getRelation 10 vecBatch `shouldBe` getRelation 10 vecSeq
it "handles large group size (10000 members)" $ do
- let updates = [(0, MRIntroduced), (5000, MRConnected), (9999, MRIntroduced)]
+ let updates = [(0, MRIntroduced), (5000, MRSubjectConnected), (9999, MRIntroduced)]
let vec = setRelations updates B.empty
B.length vec `shouldBe` 10000
getRelation 0 vec `shouldBe` MRIntroduced
- getRelation 5000 vec `shouldBe` MRConnected
+ getRelation 5000 vec `shouldBe` MRSubjectConnected
getRelation 9999 vec `shouldBe` MRIntroduced
it "all status values can be stored and retrieved" $ do
let vec1 = setRelation 0 MRNew B.empty
let vec2 = setRelation 1 MRIntroduced vec1
- let vec3 = setRelation 2 MRConnected vec2
- getRelation 0 vec3 `shouldBe` MRNew
- getRelation 1 vec3 `shouldBe` MRIntroduced
- getRelation 2 vec3 `shouldBe` MRConnected
+ let vec3 = setRelation 2 MRSubjectConnected vec2
+ let vec4 = setRelation 3 MRReferencedConnected vec3
+ let vec5 = setRelation 4 MRConnected vec4
+ getRelation 0 vec5 `shouldBe` MRNew
+ getRelation 1 vec5 `shouldBe` MRIntroduced
+ getRelation 2 vec5 `shouldBe` MRSubjectConnected
+ getRelation 3 vec5 `shouldBe` MRReferencedConnected
+ getRelation 4 vec5 `shouldBe` MRConnected
it "vector length is minimal (lazy expansion)" $ do
- let vec = setRelation 3 MRConnected B.empty
+ let vec = setRelation 3 MRSubjectConnected B.empty
B.length vec `shouldBe` 4
+
+ it "setRelation preserves existing direction" $ do
+ let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty
+ let vec2 = setRelation 0 MRConnected vec1
+ getRelation 0 vec2 `shouldBe` MRConnected
+ B.unpack vec2 `shouldBe` [0x0C] -- direction=1 preserved, status=100
+
+ describe "setRelationConnected" $ do
+ it "MRSubjectConnected on MRIntroduced -> MRSubjectConnected" $ do
+ let vec1 = setRelation 0 MRIntroduced B.empty
+ let vec2 = setRelationConnected 0 MRSubjectConnected vec1
+ getRelation 0 vec2 `shouldBe` MRSubjectConnected
+
+ it "MRReferencedConnected on MRIntroduced -> MRReferencedConnected" $ do
+ let vec1 = setRelation 0 MRIntroduced B.empty
+ let vec2 = setRelationConnected 0 MRReferencedConnected vec1
+ getRelation 0 vec2 `shouldBe` MRReferencedConnected
+
+ it "MRSubjectConnected on MRReferencedConnected -> MRConnected (complementary)" $ do
+ let vec1 = setRelation 0 MRReferencedConnected B.empty
+ let vec2 = setRelationConnected 0 MRSubjectConnected vec1
+ getRelation 0 vec2 `shouldBe` MRConnected
+
+ it "MRReferencedConnected on MRSubjectConnected -> MRConnected (complementary)" $ do
+ let vec1 = setRelation 0 MRSubjectConnected B.empty
+ let vec2 = setRelationConnected 0 MRReferencedConnected vec1
+ getRelation 0 vec2 `shouldBe` MRConnected
+
+ it "MRSubjectConnected on MRSubjectConnected -> no change" $ do
+ let vec1 = setRelation 0 MRSubjectConnected B.empty
+ let vec2 = setRelationConnected 0 MRSubjectConnected vec1
+ vec2 `shouldBe` vec1
+
+ it "MRReferencedConnected on MRReferencedConnected -> no change" $ do
+ let vec1 = setRelation 0 MRReferencedConnected B.empty
+ let vec2 = setRelationConnected 0 MRReferencedConnected vec1
+ vec2 `shouldBe` vec1
+
+ it "MRSubjectConnected on MRConnected -> no change" $ do
+ let vec1 = setRelation 0 MRConnected B.empty
+ let vec2 = setRelationConnected 0 MRSubjectConnected vec1
+ vec2 `shouldBe` vec1
+
+ it "MRReferencedConnected on MRConnected -> no change" $ do
+ let vec1 = setRelation 0 MRConnected B.empty
+ let vec2 = setRelationConnected 0 MRReferencedConnected vec1
+ vec2 `shouldBe` vec1
+
+ it "invalid status (MRConnected) -> no change" $ do
+ let vec1 = setRelation 0 MRIntroduced B.empty
+ let vec2 = setRelationConnected 0 MRConnected vec1
+ vec2 `shouldBe` vec1
+
+ it "invalid status (MRNew) -> no change" $ do
+ let vec1 = setRelation 0 MRIntroduced B.empty
+ let vec2 = setRelationConnected 0 MRNew vec1
+ vec2 `shouldBe` vec1
+
+ it "setRelationConnected preserves direction when updating" $ do
+ let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty
+ let vec2 = setRelationConnected 0 MRSubjectConnected vec1
+ getRelation' 0 vec2 `shouldBe` (IDReferencedIntroduced, MRSubjectConnected)
diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs
index b1b7dbc264..8a716ddf11 100644
--- a/tests/SchemaDump.hs
+++ b/tests/SchemaDump.hs
@@ -18,6 +18,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Database.SQLite.Simple (Query (..))
+import Simplex.Chat.Options.SQLite (chatDBFunctions)
import Simplex.Chat.Store (createChatStore)
import qualified Simplex.Chat.Store as Store
import Simplex.Messaging.Agent.Env.SQLite (createAgentStore)
@@ -63,7 +64,7 @@ testVerifySchemaDump :: IO ()
testVerifySchemaDump = withTmpFiles $ do
savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "")
savedSchema `deepseq` pure ()
- void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing)
+ void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing)
getSchema testDB appSchema `shouldReturn` savedSchema
removeFile testDB
@@ -71,14 +72,14 @@ testVerifyLintFKeyIndexes :: IO ()
testVerifyLintFKeyIndexes = withTmpFiles $ do
savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "")
savedLint `deepseq` pure ()
- void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing)
+ void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing)
getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint
removeFile testDB
testSchemaMigrations :: IO ()
testSchemaMigrations = withTmpFiles $ do
let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations
- Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing)
+ Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing)
mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations
closeDBStore st
removeFile testDB
@@ -152,7 +153,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS
updatePlans
appChatQueryPlans
chatQueryStats
- (createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing))
+ (createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing))
(\db -> do
DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)"
DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)"
@@ -161,7 +162,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS
updatePlans
appAgentQueryPlans
agentQueryStats
- (createAgentStore (DBOpts testAgentDB "" False True TQOff) (MigrationConfig MCError Nothing))
+ (createAgentStore (DBOpts testAgentDB [] "" False True TQOff) (MigrationConfig MCError Nothing))
(const $ pure ())
chatSavedPlans' == chatSavedPlans `shouldBe` True
agentSavedPlans' == agentSavedPlans `shouldBe` True