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:
wt
2026-08-18 14:49:55 +07:00
parent 9c8cff7168
commit a57e45111b
2 changed files with 149 additions and 29 deletions
+13 -2
View File
@@ -9,7 +9,7 @@
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
AES-GCM выполняется CryptoKit.
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
сообщения и сохраняет snapshot локально.
сообщения и сохраняет snapshot локально через SwiftData.
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
текстовые ответы возвращаются немедленно или через гарантированную очередь,
@@ -18,6 +18,17 @@
XMPP, отправляет запись через тот же XEP-0363/OMEMO-пайплайн и возвращает
часам отдельный результат фактической отправки.
## Локальное хранение
История чатов сохраняется в SwiftData: `ChatArchive` работает поверх `@Model`-
записей `ConversationRecord`, `MessageRecord` и `ArchiveMetadataRecord`
(`Sources/Shared/Persistence/`). Доменные типы `Conversation`/`ChatMessage`
остаются value-типами в памяти, а SwiftData — только слой долговременного
хранения. Контейнер один на приложение и фильтрует записи по `accountJID`.
Вложенные Codable-значения (реакции, call-метаданные, MAM-чекпоинты) хранятся
как `Data`-атрибуты. При первом запуске после обновления старый JSON-снапшот
(`Luma/Accounts/<hash>.json`) однократно переносится в SwiftData и удаляется.
## Контакты
`RosterModule` Martin передаёт initial roster и последующие roster-push в
@@ -205,6 +216,6 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
- APNs provider + XEP-0357 registration;
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
- MIX и реакции;
- SQLite/SQLCipher вместо JSON snapshot;
- шифрование SQLite-хранилища (SQLCipher-уровня) поверх SwiftData;
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
- share extension и notification service extension.
+136 -27
View File
@@ -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 {