store chat history in SwiftData
Replace the per-account JSON snapshot with SwiftData @Model records (ConversationRecord, MessageRecord, ArchiveMetadataRecord). The in-memory value types stay unchanged; ChatArchive now persists through a shared ModelContainer filtered by accountJID. Legacy JSON is migrated once on first load and then removed.
This commit is contained in:
1 parent
9c8cff7168
commit
a57e45111b
2 files changed
+149
-29
No files matched your search
@@ -1,6 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
actor ChatArchive {
|
||||
/// Durable per-account chat history.
|
||||
///
|
||||
/// The in-memory domain model stays the value types `Conversation` and
|
||||
/// `ChatMessage`; this type is only the persistence boundary. Storage is
|
||||
/// SwiftData (`@Model` records in `ArchiveRecords.swift`) instead of the
|
||||
/// former per-account JSON snapshot file. A one-shot migration reads the legacy
|
||||
/// JSON file on first launch and seeds the SwiftData store.
|
||||
final class ChatArchive {
|
||||
struct Snapshot: Codable, Sendable {
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
@@ -73,47 +81,148 @@ actor ChatArchive {
|
||||
static let empty = Snapshot(conversations: [], messages: [])
|
||||
}
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let accountJID: String
|
||||
private let legacyFileURL: URL
|
||||
|
||||
init(accountJID: String) {
|
||||
self.accountJID = accountJID.lowercased()
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
|
||||
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
legacyFileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
|
||||
}
|
||||
|
||||
func load() -> Snapshot {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
|
||||
func load() async -> Snapshot {
|
||||
let jid = accountJID
|
||||
let legacyURL = legacyFileURL
|
||||
return await MainActor.run {
|
||||
let context = LumaPersistence.archiveContainer.mainContext
|
||||
|
||||
guard
|
||||
let conversations = try? context.fetch(FetchDescriptor<ConversationRecord>()),
|
||||
let messages = try? context.fetch(FetchDescriptor<MessageRecord>()),
|
||||
let metadata = try? context.fetch(FetchDescriptor<ArchiveMetadataRecord>())
|
||||
else {
|
||||
return Snapshot.empty
|
||||
}
|
||||
|
||||
let accountConversations = conversations.filter { $0.accountJID == jid }
|
||||
let accountMessages = messages.filter { $0.accountJID == jid }
|
||||
let accountMetadata = metadata.filter { $0.accountJID == jid }
|
||||
|
||||
if !accountConversations.isEmpty || !accountMessages.isEmpty || !accountMetadata.isEmpty {
|
||||
return Self.snapshot(
|
||||
conversations: accountConversations,
|
||||
messages: accountMessages,
|
||||
metadata: accountMetadata.first
|
||||
)
|
||||
}
|
||||
|
||||
// The SwiftData store is empty for this account: migrate the legacy
|
||||
// JSON snapshot once, if it still exists.
|
||||
if let legacy = Self.decodeLegacySnapshot(from: legacyURL) {
|
||||
try? Self.replaceAll(with: legacy, accountJID: jid, context: context)
|
||||
try? context.save()
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
return legacy
|
||||
}
|
||||
|
||||
return .empty
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func save(_ snapshot: Snapshot) throws {
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: [.atomic])
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: fileURL.path
|
||||
func save(_ snapshot: Snapshot) async throws {
|
||||
let jid = accountJID
|
||||
let legacyURL = legacyFileURL
|
||||
try await MainActor.run {
|
||||
let context = LumaPersistence.archiveContainer.mainContext
|
||||
try Self.replaceAll(with: snapshot, accountJID: jid, context: context)
|
||||
try context.save()
|
||||
// The legacy file is stale once SwiftData owns the history.
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func erase() async throws {
|
||||
let jid = accountJID
|
||||
let legacyURL = legacyFileURL
|
||||
try await MainActor.run {
|
||||
let context = LumaPersistence.archiveContainer.mainContext
|
||||
|
||||
for record in try context.fetch(FetchDescriptor<ConversationRecord>())
|
||||
where record.accountJID == jid {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<MessageRecord>())
|
||||
where record.accountJID == jid {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<ArchiveMetadataRecord>())
|
||||
where record.accountJID == jid {
|
||||
context.delete(record)
|
||||
}
|
||||
try context.save()
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func snapshot(
|
||||
conversations: [ConversationRecord],
|
||||
messages: [MessageRecord],
|
||||
metadata: ArchiveMetadataRecord?
|
||||
) -> Snapshot {
|
||||
Snapshot(
|
||||
conversations: conversations.map { $0.value },
|
||||
messages: messages.map { $0.value },
|
||||
locallyDeletedMessageIDs: metadata.flatMap {
|
||||
ArchiveBlobCodec.decode([String].self, from: $0.locallyDeletedMessageIDsData).map { Set($0) }
|
||||
} ?? [],
|
||||
rosterContactJIDs: metadata.flatMap {
|
||||
ArchiveBlobCodec.decode([String].self, from: $0.rosterContactJIDsData).map { Set($0) }
|
||||
} ?? [],
|
||||
lastSuccessfulMAMSync: metadata?.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: metadata?.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: metadata.flatMap {
|
||||
ArchiveBlobCodec.decode([MAMArchiveKey: MAMArchiveCheckpoint].self, from: $0.mamCheckpointsData)
|
||||
} ?? [:]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
private static func replaceAll(
|
||||
with snapshot: Snapshot,
|
||||
accountJID: String,
|
||||
context: ModelContext
|
||||
) throws {
|
||||
for record in try context.fetch(FetchDescriptor<ConversationRecord>())
|
||||
where record.accountJID == accountJID {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<MessageRecord>())
|
||||
where record.accountJID == accountJID {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<ArchiveMetadataRecord>())
|
||||
where record.accountJID == accountJID {
|
||||
context.delete(record)
|
||||
}
|
||||
|
||||
for conversation in snapshot.conversations {
|
||||
context.insert(ConversationRecord(accountJID: accountJID, conversation: conversation))
|
||||
}
|
||||
for message in snapshot.messages {
|
||||
context.insert(MessageRecord(accountJID: accountJID, message: message))
|
||||
}
|
||||
context.insert(ArchiveMetadataRecord(accountJID: accountJID, snapshot: snapshot))
|
||||
}
|
||||
|
||||
private static func decodeLegacySnapshot(from url: URL) -> Snapshot? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
return try? decoder.decode(Snapshot.self, from: data)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
|
||||
Reference in new issue
Block a user