Create ArchiveRecords.swift

This commit is contained in:
wt
2026-08-18 14:50:04 +07:00
parent a57e45111b
commit 6489b98fd2
@@ -0,0 +1,230 @@
import Foundation
import SwiftData
/// The single SwiftData container backing durable chat history. It lives on a
/// plain (non-actor, non-`@MainActor`) type so it can be read from arbitrary
/// isolation without hopping.
enum LumaPersistence {
static let archiveContainer: ModelContainer = {
let schema = Schema([
ConversationRecord.self,
MessageRecord.self,
ArchiveMetadataRecord.self,
])
do {
return try ModelContainer(
for: schema,
configurations: [ModelConfiguration("LumaArchive", schema: schema)]
)
} catch {
// A corrupted on-disk store must not prevent sign-in. Degrade to an
// in-memory store for the current session instead.
do {
return try ModelContainer(
for: schema,
configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]
)
} catch {
fatalError("Unable to create the Luma SwiftData container: \(error)")
}
}
}()
}
/// Encodes small Codable value types (reactions, call-history metadata, MAM
/// checkpoints, ID sets) into `Data` attributes so the `@Model` records remain
/// composed only of SwiftData-native scalar types.
enum ArchiveBlobCodec {
private static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
return encoder
}()
private static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
return decoder
}()
static func encode<T: Encodable>(_ value: T) -> Data? {
try? encoder.encode(value)
}
static func decode<T: Decodable>(_ type: T.Type, from data: Data?) -> T? {
guard let data else { return nil }
return try? decoder.decode(type, from: data)
}
}
@Model
final class ConversationRecord {
var accountJID: String
var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
var unreadCount: Int
var isOnline: Bool
var isPinned: Bool
var encryptionPreferenceRaw: String
var kindRaw: String
var groupNickname: String?
var isGroupJoined: Bool
var shouldAutojoin: Bool
var occupantCount: Int
var invitedBy: String?
init(accountJID: String, conversation: Conversation) {
self.accountJID = accountJID
self.jid = conversation.jid
self.displayName = conversation.displayName
self.lastMessage = conversation.lastMessage
self.lastActivity = conversation.lastActivity
self.unreadCount = conversation.unreadCount
self.isOnline = conversation.isOnline
self.isPinned = conversation.isPinned
self.encryptionPreferenceRaw = conversation.encryptionPreference.rawValue
self.kindRaw = conversation.kind.rawValue
self.groupNickname = conversation.groupNickname
self.isGroupJoined = conversation.isGroupJoined
self.shouldAutojoin = conversation.shouldAutojoin
self.occupantCount = conversation.occupantCount
self.invitedBy = conversation.invitedBy
}
var value: Conversation {
Conversation(
jid: jid,
displayName: displayName,
lastMessage: lastMessage,
lastActivity: lastActivity,
unreadCount: unreadCount,
isOnline: isOnline,
isPinned: isPinned,
encryptionPreference: EncryptionPreference(rawValue: encryptionPreferenceRaw) ?? .inheritGlobal,
kind: Conversation.Kind(rawValue: kindRaw) ?? .direct,
groupNickname: groupNickname,
isGroupJoined: isGroupJoined,
shouldAutojoin: shouldAutojoin,
occupantCount: occupantCount,
invitedBy: invitedBy
)
}
}
@Model
final class MessageRecord {
var accountJID: String
var messageID: String
var conversationID: String
var senderJID: String
var body: String
var timestamp: Date
var directionRaw: String
var deliveryRaw: String
var securityRaw: String
var kindRaw: String
var remoteAttachmentURL: String?
var localFilename: String?
var mimeType: String?
var duration: Double?
var byteCount: Int?
var encryptionFingerprint: String?
var editedAt: Date?
var replyToID: String?
var replyToJID: String?
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
var originID: String?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistoryData: Data?
var reactionsData: Data?
init(accountJID: String, message: ChatMessage) {
self.accountJID = accountJID
self.messageID = message.id
self.conversationID = message.conversationID
self.senderJID = message.senderJID
self.body = message.body
self.timestamp = message.timestamp
self.directionRaw = message.direction.rawValue
self.deliveryRaw = message.delivery.rawValue
self.securityRaw = message.security.rawValue
self.kindRaw = message.kind.rawValue
self.remoteAttachmentURL = message.remoteAttachmentURL
self.localFilename = message.localFilename
self.mimeType = message.mimeType
self.duration = message.duration
self.byteCount = message.byteCount
self.encryptionFingerprint = message.encryptionFingerprint
self.editedAt = message.editedAt
self.replyToID = message.replyToID
self.replyToJID = message.replyToJID
self.replyPreview = message.replyPreview
self.forwardedFrom = message.forwardedFrom
self.retractedAt = message.retractedAt
self.originID = message.originID
self.stanzaID = message.stanzaID
self.senderDisplayName = message.senderDisplayName
self.isGroupMessage = message.isGroupMessage
self.callHistoryData = message.callHistory.flatMap { ArchiveBlobCodec.encode($0) }
self.reactionsData = ArchiveBlobCodec.encode(message.reactions)
}
var value: ChatMessage {
ChatMessage(
id: messageID,
conversationID: conversationID,
senderJID: senderJID,
body: body,
timestamp: timestamp,
direction: ChatMessage.Direction(rawValue: directionRaw) ?? .incoming,
delivery: ChatMessage.Delivery(rawValue: deliveryRaw) ?? .sent,
security: ChatMessage.Security(rawValue: securityRaw) ?? .plaintext,
kind: ChatMessage.Kind(rawValue: kindRaw) ?? .text,
remoteAttachmentURL: remoteAttachmentURL,
localFilename: localFilename,
mimeType: mimeType,
duration: duration,
byteCount: byteCount,
encryptionFingerprint: encryptionFingerprint,
editedAt: editedAt,
replyToID: replyToID,
replyToJID: replyToJID,
replyPreview: replyPreview,
forwardedFrom: forwardedFrom,
retractedAt: retractedAt,
originID: originID,
stanzaID: stanzaID,
senderDisplayName: senderDisplayName,
isGroupMessage: isGroupMessage,
callHistory: ArchiveBlobCodec.decode(CallHistoryMetadata.self, from: callHistoryData),
reactions: ArchiveBlobCodec.decode([MessageReaction].self, from: reactionsData) ?? []
)
}
}
@Model
final class ArchiveMetadataRecord {
var accountJID: String
var schemaVersion: Int
var locallyDeletedMessageIDsData: Data
var rosterContactJIDsData: Data
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpointsData: Data
init(accountJID: String, snapshot: ChatArchive.Snapshot) {
self.accountJID = accountJID
self.schemaVersion = snapshot.schemaVersion
self.locallyDeletedMessageIDsData = ArchiveBlobCodec.encode(Array(snapshot.locallyDeletedMessageIDs)) ?? Data()
self.rosterContactJIDsData = ArchiveBlobCodec.encode(Array(snapshot.rosterContactJIDs)) ?? Data()
self.lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor
self.mamCheckpointsData = ArchiveBlobCodec.encode(snapshot.mamCheckpoints) ?? Data()
}
}