mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			393 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			393 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import GRDB
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| enum _001_InitialSetupMigration: Migration {
 | |
|     static let target: TargetMigrations.Identifier = .messagingKit
 | |
|     static let identifier: String = "initialSetup" // stringlint:disable
 | |
|     static let needsConfigSync: Bool = false
 | |
|     static let minExpectedRunDuration: TimeInterval = 0.1
 | |
|     static let fetchedTables: [(TableRecord & FetchableRecord).Type] = []
 | |
|     static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [
 | |
|         Contact.self, Profile.self, SessionThread.self, DisappearingMessagesConfiguration.self,
 | |
|         ClosedGroup.self, ClosedGroupKeyPair.self, OpenGroup.self, Capability.self, BlindedIdLookup.self,
 | |
|         GroupMember.self, Interaction.self, RecipientState.self, Attachment.self,
 | |
|         InteractionAttachment.self, Quote.self, LinkPreview.self, ControlMessageProcessRecord.self,
 | |
|         ThreadTypingIndicator.self
 | |
|     ]
 | |
|     static let droppedTables: [(TableRecord & FetchableRecord).Type] = []
 | |
|     
 | |
|     public static let fullTextSearchTokenizer: FTS5TokenizerDescriptor = {
 | |
|         // Define the tokenizer to be used in all the FTS tables
 | |
|         // https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#fts5-tokenizers
 | |
|         return .porter(wrapping: .unicode61())
 | |
|     }()
 | |
|     
 | |
|     static func migrate(_ db: Database, using dependencies: Dependencies) throws {
 | |
|         try db.create(table: Contact.self) { t in
 | |
|             t.column(.id, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|             t.column(.isTrusted, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.isApproved, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.isBlocked, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.didApproveMe, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.hasBeenBlocked, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: Profile.self) { t in
 | |
|             t.column(.id, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|             t.column(.name, .text).notNull()
 | |
|             t.column(.nickname, .text)
 | |
|             t.column(.profilePictureUrl, .text)
 | |
|             t.column(.profilePictureFileName, .text)
 | |
|             t.column(.profileEncryptionKey, .blob)
 | |
|         }
 | |
|         
 | |
|         /// Create a full-text search table synchronized with the Profile table
 | |
|         try db.create(virtualTable: Profile.fullTextSearchTableName, using: FTS5()) { t in
 | |
|             t.synchronize(withTable: Profile.databaseTableName)
 | |
|             t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
 | |
|             
 | |
|             t.column(Profile.Columns.nickname.name)
 | |
|             t.column(Profile.Columns.name.name)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: SessionThread.self) { t in
 | |
|             t.column(.id, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|             t.column(.variant, .integer).notNull()
 | |
|             t.column(.creationDateTimestamp, .double).notNull()
 | |
|             t.column(.shouldBeVisible, .boolean).notNull()
 | |
|             t.deprecatedColumn(name: "isPinned", .boolean).notNull() // stringlint:disable
 | |
|             t.column(.messageDraft, .text)
 | |
|             t.column(.notificationSound, .integer)
 | |
|             t.column(.mutedUntilTimestamp, .double)
 | |
|             t.column(.onlyNotifyForMentions, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: DisappearingMessagesConfiguration.self) { t in
 | |
|             t.column(.threadId, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|                 .references(SessionThread.self, onDelete: .cascade)   // Delete if Thread deleted
 | |
|             t.column(.isEnabled, .boolean)
 | |
|                 .defaults(to: false)
 | |
|                 .notNull()
 | |
|             t.column(.durationSeconds, .double)
 | |
|                 .defaults(to: 0)
 | |
|                 .notNull()
 | |
|         }
 | |
|         
 | |
|         try db.create(table: ClosedGroup.self) { t in
 | |
|             t.column(.threadId, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|                 .references(SessionThread.self, onDelete: .cascade)   // Delete if Thread deleted
 | |
|             t.column(.name, .text).notNull()
 | |
|             t.column(.formationTimestamp, .double).notNull()
 | |
|         }
 | |
|         
 | |
|         /// Create a full-text search table synchronized with the ClosedGroup table
 | |
|         try db.create(virtualTable: ClosedGroup.fullTextSearchTableName, using: FTS5()) { t in
 | |
|             t.synchronize(withTable: ClosedGroup.databaseTableName)
 | |
|             t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
 | |
|             
 | |
|             t.column(ClosedGroup.Columns.name.name)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: ClosedGroupKeyPair.self) { t in
 | |
|             t.column(.threadId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(ClosedGroup.self, onDelete: .cascade)     // Delete if ClosedGroup deleted
 | |
|             t.column(.publicKey, .blob).notNull()
 | |
|             t.column(.secretKey, .blob).notNull()
 | |
|             t.column(.receivedTimestamp, .double)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             
 | |
|             t.uniqueKey([.publicKey, .secretKey, .receivedTimestamp])
 | |
|         }
 | |
|         
 | |
|         try db.create(table: OpenGroup.self) { t in
 | |
|             // Note: There is no foreign key constraint here because we need an OpenGroup entry to
 | |
|             // exist to be able to retrieve the default open group rooms - as a result we need to
 | |
|             // manually handle deletion of this object (in both OpenGroupManager and GarbageCollectionJob)
 | |
|             t.column(.threadId, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|             t.column(.server, .text)
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .notNull()
 | |
|             t.column(.roomToken, .text).notNull()
 | |
|             t.column(.publicKey, .text).notNull()
 | |
|             t.column(.isActive, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.name, .text).notNull()
 | |
|             t.column(.roomDescription, .text)
 | |
|             t.column(.imageId, .text)
 | |
|             t.column(.imageData, .blob)
 | |
|             t.column(.userCount, .integer).notNull()
 | |
|             t.column(.infoUpdates, .integer).notNull()
 | |
|             t.column(.sequenceNumber, .integer).notNull()
 | |
|             t.column(.inboxLatestMessageId, .integer).notNull()
 | |
|             t.column(.outboxLatestMessageId, .integer).notNull()
 | |
|             t.column(.pollFailureCount, .integer)
 | |
|                 .notNull()
 | |
|                 .defaults(to: 0)
 | |
|         }
 | |
|         
 | |
|         /// Create a full-text search table synchronized with the OpenGroup table
 | |
|         try db.create(virtualTable: OpenGroup.fullTextSearchTableName, using: FTS5()) { t in
 | |
|             t.synchronize(withTable: OpenGroup.databaseTableName)
 | |
|             t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
 | |
|             
 | |
|             t.column(OpenGroup.Columns.name.name)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: Capability.self) { t in
 | |
|             t.column(.openGroupServer, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.variant, .text).notNull()
 | |
|             t.column(.isMissing, .boolean).notNull()
 | |
|             
 | |
|             t.primaryKey([.openGroupServer, .variant])
 | |
|         }
 | |
|         
 | |
|         try db.create(table: BlindedIdLookup.self) { t in
 | |
|             t.column(.blindedId, .text)
 | |
|                 .primaryKey()
 | |
|             t.column(.sessionId, .text)
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.openGroupServer, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.openGroupPublicKey, .text)
 | |
|                 .notNull()
 | |
|         }
 | |
|         
 | |
|         try db.create(table: GroupMember.self) { t in
 | |
|             // Note: Since we don't know whether this will be stored against a 'ClosedGroup' or
 | |
|             // an 'OpenGroup' we add the foreign key constraint against the thread itself (which
 | |
|             // shares the same 'id' as the 'groupId') so we can cascade delete automatically
 | |
|             t.column(.groupId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(SessionThread.self, onDelete: .cascade)   // Delete if Thread deleted
 | |
|             t.column(.profileId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.role, .integer).notNull()
 | |
|         }
 | |
|         
 | |
|         try db.create(table: Interaction.self) { t in
 | |
|             t.column(.id, .integer)
 | |
|                 .notNull()
 | |
|                 .primaryKey(autoincrement: true)
 | |
|             t.column(.serverHash, .text)
 | |
|             t.column(.messageUuid, .text)
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.threadId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(SessionThread.self, onDelete: .cascade)   // Delete if Thread deleted
 | |
|             t.column(.authorId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             
 | |
|             t.column(.variant, .integer).notNull()
 | |
|             t.column(.body, .text)
 | |
|             t.column(.timestampMs, .integer)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.receivedAtTimestampMs, .integer).notNull()
 | |
|             t.column(.wasRead, .boolean)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .defaults(to: false)
 | |
|             t.column(.hasMention, .boolean)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .defaults(to: false)
 | |
|             t.column(.expiresInSeconds, .double)
 | |
|             t.column(.expiresStartedAtMs, .double)
 | |
|             t.column(.linkPreviewUrl, .text)
 | |
|             
 | |
|             t.column(.openGroupServerMessageId, .integer)
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.openGroupWhisperMods, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.openGroupWhisperTo, .text)
 | |
|             
 | |
|             /// The below unique constraints are added to prevent messages being duplicated, we need
 | |
|             /// multiple constraints to handle the different situations which can result in duplicate messages,
 | |
|             /// the following describes the different cases where messages can be duplicated:
 | |
|             ///
 | |
|             /// Threads with variants: [`contact`, `closedGroup`]:
 | |
|             ///   "Sync" messages (messages we resend to the current to ensure it appears on all linked devices):
 | |
|             ///     `threadId`                    - Unique per thread
 | |
|             ///     `authorId`                    - Unique per user
 | |
|             ///     `timestampMs`              - Very low chance of collision (especially combined with other two)
 | |
|             ///
 | |
|             ///   Standard messages #1:
 | |
|             ///     `threadId`                    - Unique per thread
 | |
|             ///     `serverHash`                - Unique per message (deterministically generated)
 | |
|             ///
 | |
|             ///   Standard messages #1:
 | |
|             ///     `threadId`                    - Unique per thread
 | |
|             ///     `messageUuid`             - Very low chance of collision (especially combined with threadId)
 | |
|             ///
 | |
|             /// Threads with variants: [`openGroup`]:
 | |
|             ///   `threadId`                                        - Unique per thread
 | |
|             ///   `openGroupServerMessageId`     - Unique for VisibleMessage's on an OpenGroup server
 | |
|             t.uniqueKey([.threadId, .authorId, .timestampMs])
 | |
|             t.uniqueKey([.threadId, .serverHash])
 | |
|             t.uniqueKey([.threadId, .messageUuid])
 | |
|             t.uniqueKey([.threadId, .openGroupServerMessageId])
 | |
|         }
 | |
|         
 | |
|         /// Create a full-text search table synchronized with the Interaction table
 | |
|         try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in
 | |
|             t.synchronize(withTable: Interaction.databaseTableName)
 | |
|             t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
 | |
|             
 | |
|             t.column(Interaction.Columns.body.name)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: RecipientState.self) { t in
 | |
|             t.column(.interactionId, .integer)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(Interaction.self, onDelete: .cascade)     // Delete if interaction deleted
 | |
|             t.column(.recipientId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.state, .integer)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.readTimestampMs, .double)
 | |
|             t.column(.mostRecentFailureText, .text)
 | |
|             
 | |
|             // We want to ensure that a recipient can only have a single state for
 | |
|             // each interaction
 | |
|             t.primaryKey([.interactionId, .recipientId])
 | |
|         }
 | |
|         
 | |
|         try db.create(table: Attachment.self) { t in
 | |
|             t.column(.id, .text)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|             t.column(.serverId, .text)
 | |
|             t.column(.variant, .integer).notNull()
 | |
|             t.column(.state, .integer)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.contentType, .text).notNull()
 | |
|             t.column(.byteCount, .integer)
 | |
|                 .notNull()
 | |
|                 .defaults(to: 0)
 | |
|             t.column(.creationTimestamp, .double)
 | |
|             t.column(.sourceFilename, .text)
 | |
|             t.column(.downloadUrl, .text)
 | |
|             t.column(.localRelativeFilePath, .text)
 | |
|             t.column(.width, .integer)
 | |
|             t.column(.height, .integer)
 | |
|             t.column(.duration, .double)
 | |
|             t.column(.isVisualMedia, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.isValid, .boolean)
 | |
|                 .notNull()
 | |
|                 .defaults(to: false)
 | |
|             t.column(.encryptionKey, .blob)
 | |
|             t.column(.digest, .blob)
 | |
|             t.column(.caption, .text)
 | |
|         }
 | |
|         
 | |
|         try db.create(table: InteractionAttachment.self) { t in
 | |
|             t.column(.albumIndex, .integer).notNull()
 | |
|             t.column(.interactionId, .integer)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(Interaction.self, onDelete: .cascade)     // Delete if interaction deleted
 | |
|             t.column(.attachmentId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(Attachment.self, onDelete: .cascade)      // Delete if attachment deleted
 | |
|         }
 | |
|         
 | |
|         try db.create(table: Quote.self) { t in
 | |
|             t.column(.interactionId, .integer)
 | |
|                 .notNull()
 | |
|                 .primaryKey()
 | |
|                 .references(Interaction.self, onDelete: .cascade)     // Delete if interaction deleted
 | |
|             t.column(.authorId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(Profile.self)
 | |
|             t.column(.timestampMs, .double).notNull()
 | |
|             t.column(.body, .text)
 | |
|             t.column(.attachmentId, .text)
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(Attachment.self, onDelete: .setNull)      // Clear if attachment deleted
 | |
|         }
 | |
|         
 | |
|         try db.create(table: LinkPreview.self) { t in
 | |
|             t.column(.url, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.timestamp, .double)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.variant, .integer).notNull()
 | |
|             t.column(.title, .text)
 | |
|             t.column(.attachmentId, .text)
 | |
|                 .indexed()                                            // Quicker querying
 | |
|                 .references(Attachment.self)                          // Managed via garbage collection
 | |
|             
 | |
|             t.primaryKey([.url, .timestamp])
 | |
|         }
 | |
|         
 | |
|         try db.create(table: ControlMessageProcessRecord.self) { t in
 | |
|             t.column(.threadId, .text)
 | |
|                 .notNull()
 | |
|                 .indexed()                                            // Quicker querying
 | |
|             t.column(.variant, .integer).notNull()
 | |
|             t.column(.timestampMs, .integer).notNull()
 | |
|             t.column(.serverExpirationTimestamp, .double)
 | |
|             
 | |
|             t.uniqueKey([.threadId, .variant, .timestampMs])
 | |
|         }
 | |
|         
 | |
|         try db.create(table: ThreadTypingIndicator.self) { t in
 | |
|             t.column(.threadId, .text)
 | |
|                 .primaryKey()
 | |
|                 .references(SessionThread.self, onDelete: .cascade)   // Delete if thread deleted
 | |
|             t.column(.timestampMs, .integer).notNull()
 | |
|         }
 | |
|         
 | |
|         Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
 | |
|     }
 | |
| }
 |