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.
424 lines
19 KiB
Swift
424 lines
19 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
//
|
|
// stringlint:disable
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import SessionUtilitiesKit
|
|
|
|
enum _001_InitialSetupMigration: Migration {
|
|
static let target: TargetMigrations.Identifier = .messagingKit
|
|
static let identifier: String = "initialSetup"
|
|
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, LegacyRecipientState.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") { 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") { 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_fts", using: FTS5()) { t in
|
|
t.synchronize(withTable: "profile")
|
|
t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
|
|
|
|
t.column("nickname")
|
|
t.column("name")
|
|
}
|
|
|
|
try db.create(table: "thread") { t in
|
|
t.column("id", .text)
|
|
.notNull()
|
|
.primaryKey()
|
|
t.column("variant", .integer).notNull()
|
|
t.column("creationDateTimestamp", .double).notNull()
|
|
t.column("shouldBeVisible", .boolean).notNull()
|
|
t.column("isPinned", .boolean).notNull()
|
|
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") { t in
|
|
t.column("threadId", .text)
|
|
.notNull()
|
|
.primaryKey()
|
|
.references("thread", 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") { t in
|
|
t.column("threadId", .text)
|
|
.notNull()
|
|
.primaryKey()
|
|
.references("thread", 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_fts", using: FTS5()) { t in
|
|
t.synchronize(withTable: "closedGroup")
|
|
t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
|
|
|
|
t.column("name")
|
|
}
|
|
|
|
try db.create(table: "closedGroupKeyPair") { t in
|
|
t.column("threadId", .text)
|
|
.notNull()
|
|
.indexed() // Quicker querying
|
|
.references("closedGroup", 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") { 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("description", .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_fts", using: FTS5()) { t in
|
|
t.synchronize(withTable: "openGroup")
|
|
t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
|
|
|
|
t.column("name")
|
|
}
|
|
|
|
try db.create(table: "capability") { 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") { 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") { 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("thread", onDelete: .cascade) // Delete if Thread deleted
|
|
t.column("profileId", .text)
|
|
.notNull()
|
|
.indexed() // Quicker querying
|
|
t.column("role", .integer).notNull()
|
|
}
|
|
|
|
try db.create(table: "interaction") { 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("thread", 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_fts", using: FTS5()) { t in
|
|
t.synchronize(withTable: "interaction")
|
|
t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
|
|
|
|
t.column("body")
|
|
}
|
|
|
|
try db.create(table: "recipientState") { t in
|
|
t.column("interactionId", .integer)
|
|
.notNull()
|
|
.indexed() // Quicker querying
|
|
.references("interaction", 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") { 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") { t in
|
|
t.column("albumIndex", .integer).notNull()
|
|
t.column("interactionId", .integer)
|
|
.notNull()
|
|
.indexed() // Quicker querying
|
|
.references("interaction", onDelete: .cascade) // Delete if interaction deleted
|
|
t.column("attachmentId", .text)
|
|
.notNull()
|
|
.indexed() // Quicker querying
|
|
.references("attachment", onDelete: .cascade) // Delete if attachment deleted
|
|
}
|
|
|
|
try db.create(table: "quote") { t in
|
|
t.column("interactionId", .integer)
|
|
.notNull()
|
|
.primaryKey()
|
|
.references("interaction", onDelete: .cascade) // Delete if interaction deleted
|
|
t.column("authorId", .text)
|
|
.notNull()
|
|
.indexed() // Quicker querying
|
|
.references("profile")
|
|
t.column("timestampMs", .double).notNull()
|
|
t.column("body", .text)
|
|
t.column("attachmentId", .text)
|
|
.indexed() // Quicker querying
|
|
.references("attachment", onDelete: .setNull) // Clear if attachment deleted
|
|
}
|
|
|
|
try db.create(table: "linkPreview") { 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") // Managed via garbage collection
|
|
|
|
t.primaryKey(["url", "timestamp"])
|
|
}
|
|
|
|
try db.create(table: "controlMessageProcessRecord") { 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") { t in
|
|
t.column("threadId", .text)
|
|
.primaryKey()
|
|
.references("thread", onDelete: .cascade) // Delete if thread deleted
|
|
t.column("timestampMs", .integer).notNull()
|
|
}
|
|
|
|
Storage.update(progress: 1, for: self, in: target, using: dependencies)
|
|
}
|
|
}
|
|
|
|
internal extension _001_InitialSetupMigration {
|
|
struct LegacyRecipientState: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
|
public static var databaseTableName: String { "recipientState" }
|
|
|
|
public typealias Columns = CodingKeys
|
|
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
|
case interactionId
|
|
case recipientId
|
|
case state
|
|
case readTimestampMs
|
|
case mostRecentFailureText
|
|
}
|
|
|
|
public enum State: Int, Codable, DatabaseValueConvertible {
|
|
case sending
|
|
case failed
|
|
case skipped
|
|
case sent
|
|
case failedToSync
|
|
case syncing
|
|
}
|
|
|
|
public let interactionId: Int64
|
|
public let recipientId: String
|
|
public let state: State
|
|
public let readTimestampMs: Int64?
|
|
public let mostRecentFailureText: String?
|
|
}
|
|
}
|